diff --git a/Api.SeaHavenIndustries.Tests/DashboardControllerTests.cs b/Api.SeaHavenIndustries.Tests/DashboardControllerTests.cs index 4ef3b58..ddd6082 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardControllerTests.cs @@ -40,7 +40,8 @@ public class DashboardControllerTests { ScheduledTomorrow = 3, PendingUplifts = 5, - AvetaPending = 7 + AvetaPending = 7, + Unassigned = 11 }); var result = await NewController(service).GetStats( @@ -50,5 +51,6 @@ public class DashboardControllerTests ((int)Prop(body, "scheduledTomorrow")).Should().Be(3); ((int)Prop(body, "pendingUplifts")).Should().Be(5); ((int)Prop(body, "avetaPending")).Should().Be(7); + ((int)Prop(body, "unassigned")).Should().Be(11); } } diff --git a/Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs b/Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs new file mode 100644 index 0000000..996b53c --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs @@ -0,0 +1,249 @@ +using System.Security.Claims; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +/// +/// The Dashboard "Unassigned" count is open work orders with no +/// dispatcher in the selected period, and it equals the Work Orders list the +/// tile drills into (GET /board/search, Dispatcher = Unassigned). +/// +public class DashboardUnassignedTests +{ + private static readonly DateOnly From = new(2026, 9, 21); + private static readonly DateOnly To = new(2026, 9, 25); + + private static readonly List OpenStatuses = Enum.GetValues() + .Where(s => s != LifecycleStatus.Completed && s != LifecycleStatus.Canceled) + .ToList(); + + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static WorkOrderAccountResolver Resolver(ApplicationDbContext ctx) + => new(new AccountDataService(ctx), new LocationDataService(ctx)); + + private static DashboardService NewDashboard(ApplicationDbContext ctx) + => new(new DashboardDataService(ctx), Resolver(ctx)); + + private static WorkOrderAdvancedSearchService NewSearch(ApplicationDbContext ctx) + => new(new WorkOrderAdvancedSearchDataService(ctx), Resolver(ctx)); + + private static ClaimsPrincipal AccountUser(int accountId, string role = "Admin", string userId = "admin-1") + => new(new ClaimsIdentity(new[] + { + new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()), + new Claim(ClaimTypes.NameIdentifier, userId), + new Claim(ClaimTypes.Role, role) + }, "test")); + + private static WorkOrder Wo( + int id, + DateTime? scheduled, + string? assignTo = null, + LifecycleStatus? status = LifecycleStatus.Scheduled, + DateOnly? targetWeek = null, + int accountId = 1, + string? legacyStatus = null, + string? statusText = null) + => new() + { + Id = id, + AccountId = accountId, + InternalWONumber = $"2000000{id:0000}", + AssignTo = assignTo, + ScheduledDate = scheduled, + ScheduleWeekOnly = targetWeek.HasValue, + TargetWeek = targetWeek, + LifecycleStatus = status, + LegacyStatus = legacyStatus, + Status = statusText, + istemplate = false + }; + + // Rows written by legacy create paths, which set Status but no LifecycleStatus. + private static void SeedLegacy(ApplicationDbContext ctx) + { + var inRange = new DateTime(2026, 9, 22); + ctx.workOrders.AddRange( + Wo(13, inRange, status: null, statusText: "Open"), // counted + Wo(14, inRange, status: null, statusText: "On Hold"), // counted: Pending + Wo(15, inRange, status: null), // counted: no status at all + Wo(16, inRange, status: null, legacyStatus: "Done", statusText: "Open"), // closed: LegacyStatus wins + Wo(17, inRange, status: null, statusText: " Cancelled "), // canceled + Wo(18, inRange, assignTo: "disp-1", status: null, statusText: "Open"), // assigned + Wo(19, inRange, status: null, legacyStatus: "Open", statusText: "Done")); // counted: LegacyStatus wins + ctx.SaveChanges(); + } + + private static void Seed(ApplicationDbContext ctx) + { + ctx.workOrders.AddRange( + Wo(1, new DateTime(2026, 9, 22)), // counted + Wo(2, new DateTime(2026, 9, 23), assignTo: ""), // counted: blank assignee + Wo(3, null, targetWeek: new DateOnly(2026, 9, 21)), // counted: week-only in range + Wo(4, new DateTime(2026, 9, 24), assignTo: "disp-1"), // assigned + Wo(5, new DateTime(2026, 9, 24), status: LifecycleStatus.Completed), + Wo(6, new DateTime(2026, 9, 24), status: LifecycleStatus.Canceled), + Wo(7, new DateTime(2026, 8, 4)), // out of range + Wo(8, null, status: LifecycleStatus.Incomplete), // undated + Wo(9, new DateTime(2026, 9, 22), accountId: 2), // other tenant + Wo(10, null, status: LifecycleStatus.Incomplete, accountId: 2)); // other tenant, undated + ctx.SaveChanges(); + } + + [Fact] + public async Task GetStatsAsync_Range_CountsOpenUnassignedScheduledInPeriod() + { + using var ctx = NewContext(); + Seed(ctx); + + var stats = await NewDashboard(ctx).GetStatsAsync( + AccountUser(1), new DashboardStatsQueryDTO { DateFrom = From, DateTo = To }, CancellationToken.None); + + stats.Unassigned.Should().Be(3); + } + + [Fact] + public async Task GetStatsAsync_AllTime_CountsEveryOpenUnassignedIncludingUndated() + { + using var ctx = NewContext(); + Seed(ctx); + + var stats = await NewDashboard(ctx).GetStatsAsync( + AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None); + + // 1, 2, 3, 7 (out of any week but open) and 8 (undated). + stats.Unassigned.Should().Be(5); + } + + [Fact] + public async Task GetStatsAsync_CountsOpenLegacyRowsWithNoLifecycleStatus() + { + using var ctx = NewContext(); + Seed(ctx); + SeedLegacy(ctx); + + var inRange = await NewDashboard(ctx).GetStatsAsync( + AccountUser(1), new DashboardStatsQueryDTO { DateFrom = From, DateTo = To }, CancellationToken.None); + var allTime = await NewDashboard(ctx).GetStatsAsync( + AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None); + + // 1, 2, 3 plus legacy 13, 14, 15 and 19. + inRange.Unassigned.Should().Be(7); + allTime.Unassigned.Should().Be(9); + } + + [Fact] + public async Task BoardSearch_StatusFilter_MatchesLegacyRowsByTheirLegacyStatus() + { + using var ctx = NewContext(); + var inRange = new DateTime(2026, 9, 22); + ctx.workOrders.AddRange( + Wo(1, inRange), + Wo(2, inRange, status: null, statusText: "scheduled"), + Wo(3, inRange, status: null, statusText: "Open"), + Wo(4, inRange, status: null, statusText: "Legacy Mystery"), + Wo(5, inRange, status: null)); + ctx.SaveChanges(); + var user = AccountUser(1); + + async Task> Ids(LifecycleStatus status) + => (await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto + { + DatePreset = WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = From, + DateTo = To, + Statuses = new List { status }, + PageSize = 200 + }, user, "admin-1")).Items.Select(row => row.Id); + + (await Ids(LifecycleStatus.Scheduled)).Should().BeEquivalentTo(new[] { 1, 2 }); + // Unknown or missing legacy text reads as Incomplete, as the Phase0 backfill set it. + (await Ids(LifecycleStatus.Incomplete)).Should().BeEquivalentTo(new[] { 3, 4, 5 }); + (await Ids(LifecycleStatus.Completed)).Should().BeEmpty(); + } + + [Fact] + public async Task GetStatsAsync_Unassigned_NeverCountsAnotherTenantsWorkOrders() + { + using var ctx = NewContext(); + Seed(ctx); + + var accountTwo = await NewDashboard(ctx).GetStatsAsync( + AccountUser(2), new DashboardStatsQueryDTO(), CancellationToken.None); + var accountTwoInRange = await NewDashboard(ctx).GetStatsAsync( + AccountUser(2), new DashboardStatsQueryDTO { DateFrom = From, DateTo = To }, CancellationToken.None); + + accountTwo.Unassigned.Should().Be(2); + accountTwoInRange.Unassigned.Should().Be(1); + } + + [Fact] + public async Task GetStatsAsync_DispatcherScope_HasNoUnassignedWork() + { + using var ctx = NewContext(); + Seed(ctx); + + var dispatcher = await NewDashboard(ctx).GetStatsAsync( + AccountUser(1, role: "Dispatcher", userId: "disp-1"), + new DashboardStatsQueryDTO(), + CancellationToken.None); + var adminPickingDispatcher = await NewDashboard(ctx).GetStatsAsync( + AccountUser(1), + new DashboardStatsQueryDTO { DispatcherId = "disp-1" }, + CancellationToken.None); + + dispatcher.Unassigned.Should().Be(0); + adminPickingDispatcher.Unassigned.Should().Be(0); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task GetStatsAsync_UnassignedMatchesTheDrillDownList(bool withRange) + { + using var ctx = NewContext(); + Seed(ctx); + SeedLegacy(ctx); + // Dated outside the board's all-weeks window: neither side may count them. + ctx.workOrders.AddRange(Wo(11, new DateTime(1999, 12, 31)), Wo(12, new DateTime(2100, 1, 1))); + ctx.SaveChanges(); + var user = AccountUser(1); + + var stats = await NewDashboard(ctx).GetStatsAsync( + user, + withRange ? new DashboardStatsQueryDTO { DateFrom = From, DateTo = To } : new DashboardStatsQueryDTO(), + CancellationToken.None); + + // What the Work Orders board sends for the tile's drill-down link. + var list = await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto + { + DatePreset = WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = withRange ? From : new DateOnly(2000, 1, 1), + DateTo = withRange ? To : new DateOnly(2099, 12, 31), + IncludeDateless = !withRange, + Dispatchers = new List { "__unassigned__" }, + Statuses = OpenStatuses, + PageSize = 200 + }, user, "admin-1"); + + stats.Unassigned.Should().Be(list.TotalCount); + var listed = list.Items.Select(row => row.Id).ToList(); + listed.Should().Contain(new[] { 13, 14, 15, 19 }); + listed.Should().NotContain(new[] { 16, 17, 18 }); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/DashboardController.cs b/Api.SeaHavenIndustries/Controllers/DashboardController.cs index b53c4bf..e40c9fd 100644 --- a/Api.SeaHavenIndustries/Controllers/DashboardController.cs +++ b/Api.SeaHavenIndustries/Controllers/DashboardController.cs @@ -33,6 +33,7 @@ namespace Api.SeaHavenIndustries.Controllers scheduledTomorrow = stats.ScheduledTomorrow, pendingUplifts = stats.PendingUplifts, avetaPending = stats.AvetaPending, + unassigned = stats.Unassigned, breakdown = stats.Breakdown, dueCount = stats.DueCount, completedDueCount = stats.CompletedDueCount, diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs index b665f2c..583aaa2 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs @@ -5,6 +5,14 @@ namespace SeaHaven.DataServices.Helpers { public static class WorkOrderBoardQueryFilters { + /// + /// The window the Work Orders board searches when no date range is selected + /// (client ADVANCED_SEARCH_ALL_WEEKS_FROM/TO), used with includeDateless. + /// Counts that drill into that list use it too, so both see the same rows. + /// + public static readonly DateOnly AllWeeksFrom = new(2000, 1, 1); + public static readonly DateOnly AllWeeksTo = new(2099, 12, 31); + public static IQueryable ApplyBaseScope(IQueryable query) => query.Where(w => w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null)); @@ -155,6 +163,44 @@ namespace SeaHaven.DataServices.Helpers || !WorkOrderBoardRegions.KnownStorageValues.Contains(w.Locations.State)))); } + /// + /// Lower-cased legacy status text and the lifecycle status it stands for: + /// the Phase0 backfill rules, kept equal to LifecycleStatusMapper.FromLegacyStatus. + /// + public static readonly IReadOnlyDictionary LegacyStatusValues = + new Dictionary(StringComparer.Ordinal) + { + ["open"] = LifecycleStatus.Incomplete, + ["incomplete"] = LifecycleStatus.Incomplete, + ["pending"] = LifecycleStatus.Pending, + ["onhold"] = LifecycleStatus.Pending, + ["on hold"] = LifecycleStatus.Pending, + ["scheduled"] = LifecycleStatus.Scheduled, + ["en route"] = LifecycleStatus.EnRoute, + ["enroute"] = LifecycleStatus.EnRoute, + ["on site"] = LifecycleStatus.OnSite, + ["onsite"] = LifecycleStatus.OnSite, + ["inprogress"] = LifecycleStatus.InProgress, + ["in progress"] = LifecycleStatus.InProgress, + ["completed"] = LifecycleStatus.Completed, + ["complete"] = LifecycleStatus.Completed, + ["done"] = LifecycleStatus.Completed, + ["closed"] = LifecycleStatus.Completed, + ["rescheduled"] = LifecycleStatus.Rescheduled, + ["cancelled"] = LifecycleStatus.Canceled, + ["canceled"] = LifecycleStatus.Canceled, + ["pending quote"] = LifecycleStatus.PendingQuote, + ["pendingquote"] = LifecycleStatus.PendingQuote, + ["pendingapproval"] = LifecycleStatus.PendingQuote, + ["pending approval"] = LifecycleStatus.PendingQuote, + }; + + /// + /// Keeps rows in . Rows with no LifecycleStatus + /// (legacy create paths still write only Status) match by their legacy + /// status, read as the Phase0 backfill does: known text maps through + /// , anything else, blank included, is Incomplete. + /// public static IQueryable ApplyStatusFilter( IQueryable query, IReadOnlyList? statuses) @@ -162,7 +208,22 @@ namespace SeaHaven.DataServices.Helpers if (statuses == null || statuses.Count == 0) return query; - return query.Where(w => w.LifecycleStatus != null && statuses.Contains(w.LifecycleStatus.Value)); + var legacyValues = LegacyStatusValues + .Where(entry => statuses.Contains(entry.Value)) + .Select(entry => entry.Key) + .ToList(); + var knownLegacyValues = LegacyStatusValues.Keys.ToList(); + var includeUnknownLegacy = statuses.Contains(LifecycleStatus.Incomplete); + + // Null check before Trim(): EF translates it, the in-memory provider runs it. + return query.Where(w => + (w.LifecycleStatus != null && statuses.Contains(w.LifecycleStatus.Value)) + || (w.LifecycleStatus == null + && (((w.LegacyStatus ?? w.Status) != null + && legacyValues.Contains((w.LegacyStatus ?? w.Status)!.Trim().ToLower())) + || (includeUnknownLegacy + && ((w.LegacyStatus ?? w.Status) == null + || !knownLegacyValues.Contains((w.LegacyStatus ?? w.Status)!.Trim().ToLower())))))); } public static IQueryable ApplyDocStatusFilter( diff --git a/SeaHaven.DataServices/Implementation/DashboardDataService.cs b/SeaHaven.DataServices/Implementation/DashboardDataService.cs index a6c03f2..33957fc 100644 --- a/SeaHaven.DataServices/Implementation/DashboardDataService.cs +++ b/SeaHaven.DataServices/Implementation/DashboardDataService.cs @@ -39,6 +39,41 @@ namespace SeaHaven.DataServices.Implementation }; } + private static readonly IReadOnlyList OpenLifecycleStatuses = Enum + .GetValues() + .Where(s => s != LifecycleStatus.Completed && s != LifecycleStatus.Canceled) + .ToList(); + + public async Task CountOpenUnassignedAsync( + int? accountId, + string? dispatcherId, + DateOnly? dateFrom, + DateOnly? dateTo, + CancellationToken cancellationToken) + { + // A dispatcher-scoped Dashboard has no unassigned work by definition. + if (dispatcherId != null) + return 0; + + var query = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()); + if (accountId is int scopedAccountId) + query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, scopedAccountId); + query = WorkOrderBoardQueryFilters.ApplyDispatcherFilter( + query, new[] { "__unassigned__" }, myWorkOrders: false, currentUserId: null); + query = WorkOrderBoardQueryFilters.ApplyStatusFilter(query, OpenLifecycleStatuses); + // All time counts what the board lists with no range: every week plus + // undated work, so the tile and its drill-down agree. + query = dateFrom is DateOnly from && dateTo is DateOnly to + ? WorkOrderBoardQueryFilters.ApplyDateRangeFilter(query, from, to) + : WorkOrderBoardQueryFilters.ApplyDateRangeFilter( + query, + WorkOrderBoardQueryFilters.AllWeeksFrom, + WorkOrderBoardQueryFilters.AllWeeksTo, + includeDateless: true); + + return await query.CountAsync(cancellationToken); + } + public async Task> GetDashboardWorkOrdersAsync( int? accountId, string? dispatcherId, diff --git a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs index 51ef7b5..ee1932d 100644 --- a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs @@ -14,6 +14,18 @@ namespace SeaHaven.DataServices.Interfaces DateOnly today, CancellationToken cancellationToken); + /// + /// Open work orders with no dispatcher in the period, counted with the + /// same filters as GET /board/search for Dispatcher=Unassigned, + /// so the Dashboard number matches the list it drills into. + /// + Task CountOpenUnassignedAsync( + int? accountId, + string? dispatcherId, + DateOnly? dateFrom, + DateOnly? dateTo, + CancellationToken cancellationToken); + Task> GetDashboardWorkOrdersAsync( int? accountId, string? dispatcherId, diff --git a/SeaHaven.Services/DTOs/DashboardDTOs.cs b/SeaHaven.Services/DTOs/DashboardDTOs.cs index 9cf76c0..7e4438f 100644 --- a/SeaHaven.Services/DTOs/DashboardDTOs.cs +++ b/SeaHaven.Services/DTOs/DashboardDTOs.cs @@ -106,6 +106,8 @@ namespace SeaHaven.Services.DTOs public int ScheduledTomorrow { get; set; } public int PendingUplifts { get; set; } public int AvetaPending { get; set; } + /// Open work orders with no dispatcher in the selected period. + public int Unassigned { get; set; } public DashboardBreakdownDTO Breakdown { get; set; } = new(); public int DueCount { get; set; } public int CompletedDueCount { get; set; } diff --git a/SeaHaven.Services/Implementation/DashboardService.cs b/SeaHaven.Services/Implementation/DashboardService.cs index 02642dc..e528b97 100644 --- a/SeaHaven.Services/Implementation/DashboardService.cs +++ b/SeaHaven.Services/Implementation/DashboardService.cs @@ -43,6 +43,12 @@ namespace SeaHaven.Services.Implementation query.DateTo, cancellationToken); var metrics = DashboardMetrics.Calculate(workOrders, DashboardBusinessTime.Today()); + var unassigned = await _dataService.CountOpenUnassignedAsync( + accountId, + dispatcherId, + query.DateFrom, + query.DateTo, + cancellationToken); var useMetrics = query.DateFrom.HasValue && query.DateTo.HasValue || dispatcherId is not null; @@ -55,6 +61,7 @@ namespace SeaHaven.Services.Implementation ScheduledTomorrow = kpis.ScheduledTomorrow, PendingUplifts = kpis.PendingUplifts, AvetaPending = kpis.AvetaPending, + Unassigned = unassigned, Breakdown = metrics.Breakdown, DueCount = metrics.DueCount, CompletedDueCount = metrics.CompletedDueCount, diff --git a/SeaHavenIndustries.Tests/LifecycleStatusMapperTests.cs b/SeaHavenIndustries.Tests/LifecycleStatusMapperTests.cs index 0a06141..5e0149f 100644 --- a/SeaHavenIndustries.Tests/LifecycleStatusMapperTests.cs +++ b/SeaHavenIndustries.Tests/LifecycleStatusMapperTests.cs @@ -1,4 +1,5 @@ using Data.SeaHavenIndustries.Enums; +using SeaHaven.DataServices.Helpers; using SeaHaven.Services.Constants; using SeaHaven.Services.Helpers; @@ -30,6 +31,48 @@ public class LifecycleStatusMapperTests Assert.Null(result); } + // Every value FromLegacyStatus recognizes: the board's status filter reads + // rows with no LifecycleStatus through its own copy of this table. + [Theory] + [InlineData("open")] + [InlineData("incomplete")] + [InlineData("pending")] + [InlineData("onhold")] + [InlineData("on hold")] + [InlineData("scheduled")] + [InlineData("en route")] + [InlineData("enroute")] + [InlineData("on site")] + [InlineData("onsite")] + [InlineData("inprogress")] + [InlineData("in progress")] + [InlineData("completed")] + [InlineData("complete")] + [InlineData("done")] + [InlineData("closed")] + [InlineData("rescheduled")] + [InlineData("cancelled")] + [InlineData("canceled")] + [InlineData("pending quote")] + [InlineData("pendingquote")] + [InlineData("pendingapproval")] + [InlineData("pending approval")] + public void BoardStatusFilterLegacyTable_AgreesWithFromLegacyStatus(string legacy) + { + Assert.Equal( + LifecycleStatusMapper.FromLegacyStatus(legacy), + WorkOrderBoardQueryFilters.LegacyStatusValues[legacy]); + } + + [Fact] + public void BoardStatusFilterLegacyTable_HasNoValueTheMapperRejects() + { + Assert.Equal(23, WorkOrderBoardQueryFilters.LegacyStatusValues.Count); + Assert.All( + WorkOrderBoardQueryFilters.LegacyStatusValues, + entry => Assert.Equal(entry.Value, LifecycleStatusMapper.FromLegacyStatus(entry.Key))); + } + [Theory] [InlineData(LifecycleStatus.EnRoute, "En Route")] [InlineData(LifecycleStatus.PendingQuote, "Pending Quote")]