diff --git a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs index 84b86bc..78fe0ac 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs @@ -282,6 +282,92 @@ public sealed class UpliftQueueReadTests item.Status.Should().Be("Pending"); } + [Fact] + public async Task List_MapsDetailModalWorkOrderContext_DispatcherTechnicianAndScheduledDate() + { + using var context = NewContext(); + var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-81", "SITE-N", "Plumbing"); + vendor.ContactName = " Tom Tech "; + workOrder.ScheduledDate = new DateTime(2026, 4, 10, 9, 30, 0); + workOrder.AssignTo = "dispatcher-1"; + context.Users.Add(new ApplicationUser { Id = "dispatcher-1", FirstName = "Dana", LastName = "Ruiz" }); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-81"); + context.DispatchUpliftRequests.Add(Request(dispatch, "Pending", new DateTime(2026, 4, 1))); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); + + var item = result.Items.Should().ContainSingle().Subject; + item.WorkOrderScheduledDate.Should().Be(new DateTime(2026, 4, 10, 9, 30, 0)); + item.WorkOrderDispatcherName.Should().Be("Dana Ruiz"); + item.TechnicianName.Should().Be("Tom Tech"); + item.VendorCompanyName.Should().Be("Gateway"); + } + + [Fact] + public async Task List_DetailModalWorkOrderContext_IsNullForUnassignedUnscheduledWorkOrder() + { + using var context = NewContext(); + var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-82", "SITE-N", "Plumbing"); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-82"); + context.DispatchUpliftRequests.Add(Request(dispatch, "Pending", new DateTime(2026, 4, 1))); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); + + var item = result.Items.Should().ContainSingle().Subject; + item.WorkOrderScheduledDate.Should().BeNull(); + item.WorkOrderDispatcherName.Should().BeNull(); + item.TechnicianName.Should().BeNull(); + } + + [Fact] + public async Task List_DetailModalWorkOrderContext_NeverCrossesAccounts() + { + // Two tenants' work orders sit side by side. Each queue row must carry the + // dispatcher, technician and schedule of its own work order and vendor only; + // another account's work order never supplies them. + using var context = NewContext(); + var accountA = new Accounts { Name = "Account A" }; + var accountB = new Accounts { Name = "Account B" }; + context.AddRange(accountA, accountB); + context.Users.AddRange( + new ApplicationUser { Id = "dispatcher-a", FirstName = "Ann", LastName = "Alpha" }, + new ApplicationUser { Id = "dispatcher-b", FirstName = "Ben", LastName = "Beta" }); + await context.SaveChangesAsync(); + + var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC", "Vendor A"); + vendorA.ContactName = "Tech A"; + workOrderA.AccountId = accountA.Id; + workOrderA.AssignTo = "dispatcher-a"; + workOrderA.ScheduledDate = new DateTime(2026, 5, 1); + var (vendorB, workOrderB) = await SeedWorkOrderAsync(context, "WO-B", "SITE-B", "Roofing", "Vendor B"); + vendorB.ContactName = "Tech B"; + workOrderB.AccountId = accountB.Id; + workOrderB.AssignTo = "dispatcher-b"; + workOrderB.ScheduledDate = new DateTime(2026, 6, 1); + var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A"); + var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B"); + context.DispatchUpliftRequests.AddRange( + Request(dispatchA, "Pending", new DateTime(2026, 4, 1)), + Request(dispatchB, "Pending", new DateTime(2026, 4, 2))); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); + + var rowA = result.Items.Should().ContainSingle(i => i.WorkOrderId == workOrderA.Id).Subject; + rowA.WorkOrderDispatcherName.Should().Be("Ann Alpha"); + rowA.TechnicianName.Should().Be("Tech A"); + rowA.WorkOrderScheduledDate.Should().Be(new DateTime(2026, 5, 1)); + var rowB = result.Items.Should().ContainSingle(i => i.WorkOrderId == workOrderB.Id).Subject; + rowB.WorkOrderDispatcherName.Should().Be("Ben Beta"); + rowB.TechnicianName.Should().Be("Tech B"); + rowB.WorkOrderScheduledDate.Should().Be(new DateTime(2026, 6, 1)); + } + [Fact] public async Task List_ResolvesWorkOrderContext_ForDispatchLinkedOnlyThroughDispatchWorkOrders() { diff --git a/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs b/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs index 885419d..031cf01 100644 --- a/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs +++ b/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs @@ -85,6 +85,12 @@ namespace Data.SeaHavenIndustries public string? WorkOrderNumber { get; set; } public string? WorkOrderSiteCode { get; set; } public string? WorkOrderService { get; set; } + // SH-209: detail modal context, resolved from the same effective work order + // and the requesting dispatch's vendor. + public DateTime? WorkOrderScheduledDate { get; set; } + public string? WorkOrderDispatcherFirstName { get; set; } + public string? WorkOrderDispatcherLastName { get; set; } + public string? TechnicianName { get; set; } public bool WorkOrderClosed { get; set; } public int AttachmentCount { get; set; } public string? RequestedByVendorName { get; set; } diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index 788c754..7db6a01 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -79,6 +79,18 @@ namespace SeaHaven.DataServices.Implementation WorkOrderNumber = x.workOrder != null ? x.workOrder.InternalWONumber : null, WorkOrderSiteCode = x.workOrder != null ? x.workOrder.SiteCode : null, WorkOrderService = x.workOrder != null ? x.workOrder.Service : null, + // SH-209: the detail modal reads its work-order context from this row, + // so it never depends on a separate work-order fetch. Technician + // follows the board convention (vendor contact) for the requesting + // dispatch's vendor, the same vendor as VendorCompanyName. + WorkOrderScheduledDate = x.workOrder != null ? x.workOrder.ScheduledDate : null, + WorkOrderDispatcherFirstName = x.workOrder != null && x.workOrder.AssignToUser != null + ? x.workOrder.AssignToUser.FirstName + : null, + WorkOrderDispatcherLastName = x.workOrder != null && x.workOrder.AssignToUser != null + ? x.workOrder.AssignToUser.LastName + : null, + TechnicianName = x.v != null ? x.v.ContactName : null, // SH-208: a work order is closed for uplift decisions once its // lifecycle reaches a terminal state; mirrors the SH-196 revoke guard. WorkOrderClosed = x.workOrder != null diff --git a/SeaHaven.Services/DTOs/UpliftDTOs.cs b/SeaHaven.Services/DTOs/UpliftDTOs.cs index b2b8544..24cde20 100644 --- a/SeaHaven.Services/DTOs/UpliftDTOs.cs +++ b/SeaHaven.Services/DTOs/UpliftDTOs.cs @@ -12,6 +12,10 @@ namespace SeaHaven.Services.DTOs public string? WorkOrderNumber { get; set; } public string? WorkOrderSite { get; set; } public string? WorkOrderService { get; set; } + // SH-209: detail modal work-order context. + public DateTime? WorkOrderScheduledDate { get; set; } + public string? WorkOrderDispatcherName { get; set; } + public string? TechnicianName { get; set; } public string? RequestedByName { get; set; } public string? DecidedByName { get; set; } public decimal? CurrentNTE { get; set; } diff --git a/SeaHaven.Services/Implementation/UpliftService.cs b/SeaHaven.Services/Implementation/UpliftService.cs index 5144563..5459c36 100644 --- a/SeaHaven.Services/Implementation/UpliftService.cs +++ b/SeaHaven.Services/Implementation/UpliftService.cs @@ -49,6 +49,9 @@ namespace SeaHaven.Services.Implementation WorkOrderNumber = r.WorkOrderNumber, WorkOrderSite = r.WorkOrderSiteCode, WorkOrderService = r.WorkOrderService, + WorkOrderScheduledDate = r.WorkOrderScheduledDate, + WorkOrderDispatcherName = ResolveRequestedByName(null, r.WorkOrderDispatcherFirstName, r.WorkOrderDispatcherLastName), + TechnicianName = string.IsNullOrWhiteSpace(r.TechnicianName) ? null : r.TechnicianName.Trim(), RequestedByName = ResolveRequestedByName(r.RequestedByVendorName, r.RequestedByFirstName, r.RequestedByLastName), DecidedByName = ResolveRequestedByName(null, r.DecidedByFirstName, r.DecidedByLastName), CurrentNTE = r.CurrentNTE, diff --git a/SeaHavenIndustries.Tests/UpliftQueueReadRelationalTests.cs b/SeaHavenIndustries.Tests/UpliftQueueReadRelationalTests.cs index 6fdc8e2..d9f2cce 100644 --- a/SeaHavenIndustries.Tests/UpliftQueueReadRelationalTests.cs +++ b/SeaHavenIndustries.Tests/UpliftQueueReadRelationalTests.cs @@ -80,6 +80,91 @@ public sealed class UpliftQueueReadRelationalTests Assert.True(item.WorkOrderClosed); } + [Fact] + public async Task GetPagedAsync_ResolvesDetailModalContext_PerAccountWorkOrder() + { + // SH-209: dispatcher (work-order assignee navigation), technician (vendor + // contact) and scheduled date must translate to SQL through the effective + // work-order subquery, and each row resolves them from its own account's + // work order only. + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using var context = new SqliteUpliftTestDbContext(options); + await context.Database.EnsureCreatedAsync(); + + var accountA = new Accounts { Name = "Account A" }; + var accountB = new Accounts { Name = "Account B" }; + var dispatcherA = new ApplicationUser { Id = "dispatcher-a", FirstName = "Ann", LastName = "Alpha" }; + var vendorA = new Vendor { CompanyName = "Vendor A", ContactName = "Tech A", IsActive = true }; + var vendorB = new Vendor { CompanyName = "Vendor B", ContactName = "Tech B", IsActive = true }; + context.AddRange(accountA, accountB, dispatcherA, vendorA, vendorB); + await context.SaveChangesAsync(); + + var workOrderA = new WorkOrder + { + InternalWONumber = "WO-A", + WorkerOrderTitle = "Repair", + AccountId = accountA.Id, + AssignTo = dispatcherA.Id, + ScheduledDate = new DateTime(2026, 5, 1, 8, 0, 0) + }; + var workOrderB = new WorkOrder + { + InternalWONumber = "WO-B", + WorkerOrderTitle = "Repair", + AccountId = accountB.Id + }; + context.AddRange(workOrderA, workOrderB); + await context.SaveChangesAsync(); + + var dispatchA = new Dispatch { VendorId = vendorA.Id, WorkOrderId = workOrderA.Id, DispatchNumber = "DIS-A", Status = "Scheduled" }; + var dispatchB = new Dispatch { VendorId = vendorB.Id, WorkOrderId = workOrderB.Id, DispatchNumber = "DIS-B", Status = "Scheduled" }; + context.Dispatches.AddRange(dispatchA, dispatchB); + await context.SaveChangesAsync(); + + context.DispatchUpliftRequests.AddRange( + new DispatchUpliftRequest + { + DispatchId = dispatchA.Id, + Status = "Pending", + CreatedDate = new DateTime(2026, 4, 1), + RequiredTier = 1, + RequestedNTE = 100m, + NotificationStatus = "Pending" + }, + new DispatchUpliftRequest + { + DispatchId = dispatchB.Id, + Status = "Pending", + CreatedDate = new DateTime(2026, 4, 2), + RequiredTier = 1, + RequestedNTE = 200m, + NotificationStatus = "Pending" + }); + await context.SaveChangesAsync(); + + var data = new UpliftDataService(context); + + var (total, items) = await data.GetPagedAsync("Pending", null, 1, 25, CancellationToken.None); + + Assert.Equal(2, total); + var rowA = Assert.Single(items, i => i.WorkOrderId == workOrderA.Id); + Assert.Equal("Ann", rowA.WorkOrderDispatcherFirstName); + Assert.Equal("Alpha", rowA.WorkOrderDispatcherLastName); + Assert.Equal("Tech A", rowA.TechnicianName); + Assert.Equal(new DateTime(2026, 5, 1, 8, 0, 0), rowA.WorkOrderScheduledDate); + var rowB = Assert.Single(items, i => i.WorkOrderId == workOrderB.Id); + Assert.Null(rowB.WorkOrderDispatcherFirstName); + Assert.Null(rowB.WorkOrderDispatcherLastName); + Assert.Equal("Tech B", rowB.TechnicianName); + Assert.Null(rowB.WorkOrderScheduledDate); + } + private sealed class SqliteUpliftTestDbContext : ApplicationDbContext { public SqliteUpliftTestDbContext(DbContextOptions options)