diff --git a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs index 0af8f00..de6a9a6 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs @@ -1,7 +1,12 @@ +using System.Security.Claims; using Data.SeaHavenIndustries; using FluentAssertions; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; using SeaHaven.Services.Implementation; using Xunit; @@ -17,33 +22,647 @@ public class DashboardServiceTests return new ApplicationDbContext(options); } - private static DashboardService NewService(ApplicationDbContext ctx) => - new(new DashboardDataService(ctx)); + private static DashboardService NewService(ApplicationDbContext ctx) + { + var resolver = new WorkOrderAccountResolver( + new AccountDataService(ctx), + new LocationDataService(ctx)); + return new DashboardService(new DashboardDataService(ctx), resolver); + } - private static WorkOrder Wo(string? status, bool? isTemplate = false, string? assignTo = null) => - new() { Status = status, istemplate = isTemplate, AssignTo = assignTo }; + private static ClaimsPrincipal AccountUser( + int accountId, + string userId = "dispatcher-1", + string role = "Admin") + { + var claims = new[] + { + new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()), + new Claim(ClaimTypes.NameIdentifier, userId), + new Claim(ClaimTypes.Role, role) + }; + return new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); + } + + private static ClaimsPrincipal OrgWideUser() + { + var claims = new[] + { + new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll), + new Claim(ClaimTypes.Role, "Admin") + }; + return new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); + } [Fact] public async Task GetStatsAsync_CountsByStatusExcludingTemplates() { using var ctx = NewContext(); ctx.workOrders.AddRange( - Wo("Open", isTemplate: false), - Wo("Open", isTemplate: false, assignTo: "u1"), - Wo("In Progress"), - Wo("On Hold"), - Wo("Done"), - Wo("Done"), - Wo("Open", isTemplate: true), - Wo("Cancelled", isTemplate: false) + new WorkOrder { AccountId = 1, Status = "Open", istemplate = false }, + new WorkOrder { AccountId = 1, Status = "Open", istemplate = false, AssignTo = "u1" }, + new WorkOrder { AccountId = 1, Status = "In Progress" }, + new WorkOrder { AccountId = 1, Status = "On Hold" }, + new WorkOrder { AccountId = 1, Status = "Done" }, + new WorkOrder { AccountId = 1, Status = "Done" }, + new WorkOrder { AccountId = 1, Status = "Open", istemplate = true }, + new WorkOrder { AccountId = 1, Status = "Cancelled" }, + new WorkOrder { AccountId = 2, Status = "Open" }, + new WorkOrder { Status = "Open" } ); ctx.SaveChanges(); - var stats = await NewService(ctx).GetStatsAsync(CancellationToken.None); + var stats = await NewService(ctx).GetStatsAsync( + AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None); stats.Total.Should().Be(7); stats.Open.Should().Be(4); stats.NotDispatched.Should().Be(1); stats.Completed.Should().Be(2); } + + [Fact] + public async Task GetStatsAsync_OrgWideUserSeesAllNonTemplateOrders() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder { AccountId = 1, Status = "Open" }, + new WorkOrder { AccountId = 2, Status = "Done" }, + new WorkOrder { Status = "Open", istemplate = true }); + ctx.SaveChanges(); + + var stats = await NewService(ctx).GetStatsAsync( + OrgWideUser(), new DashboardStatsQueryDTO(), CancellationToken.None); + + stats.Total.Should().Be(2); + stats.Open.Should().Be(1); + stats.Completed.Should().Be(1); + } + + [Fact] + public async Task GetStatsAsync_MissingScopeFailsClosed() + { + using var ctx = NewContext(); + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.Role, "Dispatcher") + }, "test")); + + var act = () => NewService(ctx).GetStatsAsync( + user, new DashboardStatsQueryDTO(), CancellationToken.None); + + var exception = await act.Should().ThrowAsync(); + + exception.Which.Code.Should().Be("Forbidden"); + } + + [Fact] + public async Task GetStatsAsync_UsesDashboardMetricRulesForDateRange() + { + using var ctx = NewContext(); + var today = DashboardBusinessTime.Today(); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + WorkOrderType = Data.SeaHavenIndustries.Enums.WorkOrderType.PM, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = today.ToDateTime(TimeOnly.MinValue), + OriginalDate = today.AddDays(-6) + }, + new WorkOrder + { + AccountId = 1, + WorkOrderType = Data.SeaHavenIndustries.Enums.WorkOrderType.Emergency, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.InProgress, + ScheduledDate = today.AddDays(-3).ToDateTime(TimeOnly.MinValue) + }, + new WorkOrder + { + AccountId = 1, + WorkOrderType = Data.SeaHavenIndustries.Enums.WorkOrderType.Reactive, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed, + ScheduledDate = today.ToDateTime(TimeOnly.MinValue), + OriginalDate = today.AddDays(-15), + CompletedDate = today.AddDays(-12).ToDateTime(TimeOnly.MinValue) + }, + new WorkOrder + { + AccountId = 1, + WorkOrderType = Data.SeaHavenIndustries.Enums.WorkOrderType.PM, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Incomplete, + ScheduledDate = today.AddDays(4).ToDateTime(TimeOnly.MinValue) + }); + ctx.SaveChanges(); + + var query = new DashboardStatsQueryDTO + { + DateFrom = today.AddDays(-10), + DateTo = today.AddDays(10) + }; + var stats = await NewService(ctx).GetStatsAsync( + AccountUser(1), query, CancellationToken.None); + + stats.Total.Should().Be(4); + stats.Breakdown.Overdue.Should().Be(1); + stats.Breakdown.Other.Should().Be(0); + stats.Breakdown.PM.Should().Be(2); + stats.Breakdown.Emergency.Should().Be(0); + stats.Breakdown.Reactive.Should().Be(1); + (stats.Breakdown.PM + stats.Breakdown.Emergency + stats.Breakdown.Reactive + + stats.Breakdown.Overdue + stats.Breakdown.Other).Should().Be(stats.Total); + stats.DueCount.Should().Be(3); + stats.CompletedDueCount.Should().Be(1); + stats.CompletionRate.Should().Be(33.33m); + stats.AverageResolutionDays.Should().Be(3m); + } + + [Fact] + public async Task GetStatsAsync_DispatcherOnlySeesAssignedOrders() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" }, + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" }); + ctx.SaveChanges(); + + var stats = await NewService(ctx).GetStatsAsync( + AccountUser(1, role: "Dispatcher"), new DashboardStatsQueryDTO(), CancellationToken.None); + + stats.Total.Should().Be(1); + stats.Open.Should().Be(1); + } + + [Fact] + public async Task GetWorkloadAsync_UsesRoleScopeAndExcludesTerminalOrdersFromOpenCount() + { + using var ctx = NewContext(); + ctx.Roles.Add(new IdentityRole + { + Id = "dispatcher-role", + Name = "Dispatcher", + NormalizedName = "DISPATCHER" + }); + ctx.Users.AddRange( + new ApplicationUser { Id = "dispatcher-1", FirstName = "Ada", LastName = "One" }, + new ApplicationUser { Id = "dispatcher-2", FirstName = "Ben", LastName = "Two" }); + ctx.UserRoles.AddRange( + new IdentityUserRole { UserId = "dispatcher-1", RoleId = "dispatcher-role" }, + new IdentityUserRole { UserId = "dispatcher-2", RoleId = "dispatcher-role" }); + ctx.workOrders.AddRange( + new WorkOrder { AccountId = 1, AssignTo = "dispatcher-1", Status = "Open" }, + new WorkOrder { AccountId = 1, AssignTo = "dispatcher-1", LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed }, + new WorkOrder { AccountId = 1, AssignTo = "dispatcher-2", Status = "Open" }); + ctx.SaveChanges(); + + var result = await NewService(ctx).GetWorkloadAsync( + AccountUser(1, "dispatcher-1", "Dispatcher"), + new DashboardStatsQueryDTO(), + 1, + CancellationToken.None); + + result.TotalDispatchers.Should().Be(1); + result.Items.Should().ContainSingle(); + result.Items[0].DispatcherName.Should().Be("Ada One"); + result.Items[0].TotalCount.Should().Be(2); + result.Items[0].OpenCount.Should().Be(1); + result.PageSize.Should().Be(10); + + var adminResult = await NewService(ctx).GetWorkloadAsync( + AccountUser(1), + new DashboardStatsQueryDTO(), + 1, + CancellationToken.None); + + adminResult.TotalDispatchers.Should().Be(2); + adminResult.Items.Select(item => item.DispatcherName) + .Should().ContainInOrder("Ada One", "Ben Two"); + + var performance = await NewService(ctx).GetPerformanceAsync( + AccountUser(1), + new DashboardStatsQueryDTO(), + 1, + CancellationToken.None); + + performance.TotalDispatchers.Should().Be(2); + performance.Items[0].DispatcherName.Should().Be("Ada One"); + } + + [Fact] + public async Task GetPerformanceAsync_UsesDueOnlyRateAndOriginalDateResolution() + { + using var ctx = NewContext(); + ctx.Roles.Add(new IdentityRole + { + Id = "dispatcher-role", + Name = "Dispatcher", + NormalizedName = "DISPATCHER" + }); + ctx.Users.Add(new ApplicationUser + { + Id = "dispatcher-1", + FirstName = "Ada", + LastName = "One", + Color = "#123456" + }); + ctx.UserRoles.Add(new IdentityUserRole + { + UserId = "dispatcher-1", + RoleId = "dispatcher-role" + }); + var today = DashboardBusinessTime.Today(); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + AssignTo = "dispatcher-1", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed, + ScheduledDate = today.ToDateTime(TimeOnly.MinValue), + OriginalDate = today.AddDays(-5), + CompletedDate = today.AddDays(-2).ToDateTime(TimeOnly.MinValue) + }, + new WorkOrder + { + AccountId = 1, + AssignTo = "dispatcher-1", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = today.AddDays(3).ToDateTime(TimeOnly.MinValue) + }); + ctx.SaveChanges(); + + var result = await NewService(ctx).GetPerformanceAsync( + AccountUser(1), + new DashboardStatsQueryDTO(), + 1, + CancellationToken.None); + + result.Items.Should().ContainSingle(); + result.Items[0].CompletionRate.Should().Be(100m); + result.Items[0].AverageResolutionDays.Should().Be(3m); + result.Items[0].Color.Should().Be("#123456"); + } + + [Fact] + public async Task GetRegionsAsync_UsesCanonicalBucketsAndUnmappedOther() + { + await using var ctx = NewContext(); + ctx.Locations.AddRange( + new Locations { Id = 1, State = "NY" }, + new Locations { Id = 2, State = "ca" }, + new Locations { Id = 3, State = "XX" }); + ctx.workOrders.AddRange( + new WorkOrder { AccountId = 1, LocationId = 1 }, + new WorkOrder { AccountId = 1, LocationId = 2 }, + new WorkOrder { AccountId = 1, LocationId = 3 }, + new WorkOrder { AccountId = 1, LocationId = null }); + await ctx.SaveChangesAsync(); + + var result = await NewService(ctx).GetRegionsAsync( + AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None); + + result.Items.Select(row => row.Region).Should().Equal( + "East", "Central", "West", "California", "Unmapped/Other"); + result.Items.Select(row => row.WorkOrderCount).Should().Equal(1, 0, 0, 1, 2); + } + + [Fact] + public async Task GetVendorInsightsAsync_IsCompanyWideAndUsesVendorMetrics() + { + await using var ctx = NewContext(); + ctx.VendorCompanies.Add(new VendorCompany { Id = 10, Name = "Acme Services", IsDeleted = false }); + ctx.Vendors.Add(new Vendor + { + Id = 20, + CompanyId = 10, + IsActive = true, + CompanyName = "Acme Services" + }); + ctx.Dispatches.Add(new Dispatch { Id = 30, VendorId = 20 }); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + PrimaryDispatchId = 30, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed, + ScheduledDate = DateTime.UtcNow.Date.AddDays(-3), + CompletedDate = DateTime.UtcNow.Date.AddDays(-1), + RescheduleCount = 1 + }, + new WorkOrder + { + AccountId = 1, + PrimaryDispatchId = 30, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed, + ScheduledDate = DateTime.UtcNow.Date.AddDays(-2), + CompletedDate = DateTime.UtcNow.Date.AddDays(-1) + }); + await ctx.SaveChangesAsync(); + + var result = await NewService(ctx).GetVendorInsightsAsync( + AccountUser(1), CancellationToken.None); + + result.TotalVendors.Should().Be(1); + result.Items.Should().ContainSingle(); + result.Items[0].TotalJobs.Should().Be(2); + result.Items[0].CompletionRate.Should().Be(100); + result.Items[0].RescheduleRate.Should().Be(50); + } + + [Fact] + public async Task GetTrendAsync_DailyBucketsClassifyByCalendarScheduledDate() + { + using var ctx = NewContext(); + // ScheduledDate is a stored calendar value (board writes persist `.Date`), so a work + // order buckets on its own calendar day regardless of the time-of-day component. The + // UTC hours below were previously shifted a full day by an America/New_York conversion + // (03:00 UTC -> prior evening), so they double as a regression guard: each row must now + // stay on the calendar date it was scheduled for. + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 1, 15, 3, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Canceled, + ScheduledDate = new DateTime(2026, 1, 14, 15, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed, + ScheduledDate = new DateTime(2026, 1, 15, 17, 0, 0, DateTimeKind.Utc), + CompletedDate = new DateTime(2026, 1, 16, 15, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 1, 16, 3, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 1, 17, 3, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 1, 13, 20, 0, 0, DateTimeKind.Utc) + }); + ctx.SaveChanges(); + + var query = new DashboardTrendQueryDTO + { + DateFrom = new DateOnly(2026, 1, 14), + DateTo = new DateOnly(2026, 1, 16) + }; + var trend = await NewService(ctx).GetTrendAsync( + AccountUser(1), query, CancellationToken.None); + + trend.Granularity.Should().Be("day"); + trend.Buckets.Select(bucket => bucket.Date).Should().Equal( + new DateOnly(2026, 1, 14), new DateOnly(2026, 1, 15), new DateOnly(2026, 1, 16)); + trend.Buckets.Select(bucket => bucket.Label).Should().Equal( + "2026-01-14", "2026-01-15", "2026-01-16"); + + // 01-14: only the canceled row scheduled that calendar day. + trend.Buckets[0].Total.Should().Be(1); + trend.Buckets[0].Open.Should().Be(0); + trend.Buckets[0].Canceled.Should().Be(1); + trend.Buckets[0].Completed.Should().Be(0); + trend.Buckets[0].Overdue.Should().Be(0); + + // 01-15: the 03:00 UTC scheduled row (no longer shifted to 01-14) plus the completed row. + trend.Buckets[1].Total.Should().Be(2); + trend.Buckets[1].Open.Should().Be(1); + trend.Buckets[1].Completed.Should().Be(1); + trend.Buckets[1].Canceled.Should().Be(0); + trend.Buckets[1].Overdue.Should().Be(1); + + // 01-16: the 03:00 UTC scheduled row that stays on its own day. + trend.Buckets[2].Total.Should().Be(1); + trend.Buckets[2].Open.Should().Be(1); + trend.Buckets[2].Overdue.Should().Be(1); + + trend.Buckets.Should().OnlyContain(bucket => !bucket.IsCurrent); + } + + [Fact] + public async Task GetTrendAsync_MidnightScheduledDateStaysInTodayBucketAndIsNotOverdue() + { + using var ctx = NewContext(); + var today = DashboardBusinessTime.Today(); + // A board-scheduled work order for today is stored as midnight (`.Date`). It must land + // in the Today bucket and count as open, not roll back to yesterday and read as overdue. + ctx.workOrders.Add(new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = today.ToDateTime(TimeOnly.MinValue) + }); + ctx.SaveChanges(); + + var query = new DashboardTrendQueryDTO + { + DateFrom = today.AddDays(-1), + DateTo = today.AddDays(1) + }; + var trend = await NewService(ctx).GetTrendAsync( + AccountUser(1), query, CancellationToken.None); + + var todayBucket = trend.Buckets.Single(bucket => bucket.Date == today); + todayBucket.Total.Should().Be(1); + todayBucket.Open.Should().Be(1); + todayBucket.Overdue.Should().Be(0); + + var yesterdayBucket = trend.Buckets.Single(bucket => bucket.Date == today.AddDays(-1)); + yesterdayBucket.Total.Should().Be(0); + } + + [Fact] + public async Task GetTrendAsync_ThreeMonthRangeUsesWeeklyMondayBuckets() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 6, 17, 12, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 6, 16, 3, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed, + ScheduledDate = new DateTime(2026, 9, 16, 12, 0, 0, DateTimeKind.Utc), + CompletedDate = new DateTime(2026, 9, 16, 18, 0, 0, DateTimeKind.Utc) + }); + ctx.SaveChanges(); + + var query = new DashboardTrendQueryDTO + { + Range = "3m", + DateFrom = new DateOnly(2026, 6, 17), + DateTo = new DateOnly(2026, 9, 16) + }; + var trend = await NewService(ctx).GetTrendAsync( + AccountUser(1), query, CancellationToken.None); + + trend.Granularity.Should().Be("week"); + trend.Buckets.Should().HaveCount(14); + trend.Buckets.Select(bucket => bucket.Date.DayOfWeek) + .Should().OnlyContain(day => day == DayOfWeek.Monday); + trend.Buckets[0].Date.Should().Be(new DateOnly(2026, 6, 15)); + trend.Buckets[0].Total.Should().Be(2); + trend.Buckets[0].Open.Should().Be(2); + trend.Buckets[^1].Date.Should().Be(new DateOnly(2026, 9, 14)); + trend.Buckets[^1].Total.Should().Be(1); + trend.Buckets[^1].Completed.Should().Be(1); + } + + [Fact] + public async Task GetTrendAsync_YearFilterUsesMonthlyBuckets() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed, + ScheduledDate = new DateTime(2026, 3, 20, 4, 0, 0, DateTimeKind.Utc), + CompletedDate = new DateTime(2026, 3, 21, 4, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2025, 12, 31, 20, 0, 0, DateTimeKind.Utc) + }, + // 2027-01-01 by calendar: outside the 2026 window. Previously an + // America/New_York conversion pulled it back to 2026-12-31 and into December. + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2027, 1, 1, 3, 0, 0, DateTimeKind.Utc) + }); + ctx.SaveChanges(); + + var trend = await NewService(ctx).GetTrendAsync( + AccountUser(1), new DashboardTrendQueryDTO { Year = 2026 }, CancellationToken.None); + + trend.Granularity.Should().Be("month"); + trend.Buckets.Should().HaveCount(12); + trend.Buckets[0].Label.Should().Be("2026-01-01"); + trend.Buckets[0].Total.Should().Be(1); + trend.Buckets[0].Open.Should().Be(1); + trend.Buckets[0].Overdue.Should().Be(1); + trend.Buckets[2].Label.Should().Be("2026-03-01"); + trend.Buckets[2].Total.Should().Be(1); + trend.Buckets[2].Completed.Should().Be(1); + // December stays empty: the 2027-01-01 row is no longer pulled inside the window. + trend.Buckets[11].Label.Should().Be("2026-12-01"); + trend.Buckets[11].Total.Should().Be(0); + trend.Buckets[11].Open.Should().Be(0); + trend.Today.Should().Be(DashboardBusinessTime.Today()); + } + + [Fact] + public async Task GetTrendAsync_MarksTodayBucketAsCurrent() + { + using var ctx = NewContext(); + var today = DashboardBusinessTime.Today(); + ctx.workOrders.Add(new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = today.AddDays(-2).ToDateTime(new TimeOnly(12, 0)) + }); + ctx.SaveChanges(); + + var query = new DashboardTrendQueryDTO + { + DateFrom = today.AddDays(-2), + DateTo = today.AddDays(2) + }; + var trend = await NewService(ctx).GetTrendAsync( + AccountUser(1), query, CancellationToken.None); + + trend.Today.Should().Be(today); + trend.Buckets.Should().HaveCount(5); + trend.Buckets.Count(bucket => bucket.IsCurrent).Should().Be(1); + trend.Buckets.Single(bucket => bucket.IsCurrent).Date.Should().Be(today); + trend.Buckets[0].Total.Should().Be(1); + trend.Buckets.Single(bucket => bucket.Date == today).Total.Should().Be(0); + } + + [Fact] + public async Task GetTrendAsync_DispatcherRoleAndTenantScopeMatchStats() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + AssignTo = "dispatcher-1", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 2, 2, 12, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + AssignTo = "dispatcher-2", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 2, 2, 12, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 2, + AssignTo = "dispatcher-1", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 2, 2, 12, 0, 0, DateTimeKind.Utc) + }); + ctx.SaveChanges(); + + var query = new DashboardTrendQueryDTO + { + DateFrom = new DateOnly(2026, 2, 2), + DateTo = new DateOnly(2026, 2, 3) + }; + + var dispatcherTrend = await NewService(ctx).GetTrendAsync( + AccountUser(1, "dispatcher-1", "Dispatcher"), query, CancellationToken.None); + + dispatcherTrend.Buckets.Should().HaveCount(2); + dispatcherTrend.Buckets[0].Total.Should().Be(1); + dispatcherTrend.Buckets[1].Total.Should().Be(0); + + var adminTrend = await NewService(ctx).GetTrendAsync( + AccountUser(1), query, CancellationToken.None); + + adminTrend.Buckets[0].Total.Should().Be(2); + + var otherAccountTrend = await NewService(ctx).GetTrendAsync( + AccountUser(2), query, CancellationToken.None); + + otherAccountTrend.Buckets[0].Total.Should().Be(1); + } } diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderGetByIdBindingTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderGetByIdBindingTests.cs new file mode 100644 index 0000000..2d88138 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/WorkOrderGetByIdBindingTests.cs @@ -0,0 +1,106 @@ +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; +using SeaHaven.DataServices.Models; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +/// +/// SH-374: GET api/WorkOrder/GetWorkorderById?id= must read the id from the query string, while +/// GET api/WorkOrder/{id} keeps reading it from the route. Both must return the same work order, +/// and an unknown id keeps the existing "ID not found!" response. +/// +public class WorkOrderGetByIdBindingTests +{ + private const int ExistingId = 374; + private const int UnknownId = 999_999; + + private static ControllerActionDescriptor ActionForTemplate(string relativeTemplate) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddMvcCore().AddApplicationPart(typeof(WorkOrderController).Assembly); + + using var provider = services.BuildServiceProvider(); + return provider.GetRequiredService().ActionDescriptors.Items + .OfType() + .Where(cad => cad.ControllerTypeInfo == typeof(WorkOrderController)) + .Where(cad => cad.ActionConstraints! + .OfType() + .Any(c => c.HttpMethods.Contains("GET"))) + .Single(cad => string.Equals( + cad.AttributeRouteInfo?.Template?.Trim('/'), + $"api/WorkOrder/{relativeTemplate}", + StringComparison.Ordinal)); + } + + [Theory] + [InlineData("GetWorkorderById", "Query")] + [InlineData("{id:int}", "Path")] + public void Id_binds_from_the_source_its_route_provides(string relativeTemplate, string expectedSource) + { + var action = ActionForTemplate(relativeTemplate); + + var id = action.Parameters.Single(p => p.Name == "id"); + id.BindingInfo.Should().NotBeNull(); + id.BindingInfo!.BindingSource!.Id.Should().Be(expectedSource); + } + + private static (WorkOrderController Controller, WorkOrderDetailReadModel Existing) NewController() + { + var existing = new WorkOrderDetailReadModel { Id = ExistingId, Title = "Leaking roof" }; + var service = new Mock(); + service.Setup(s => s.GetWorkOrderDetailAsync(ExistingId, It.IsAny())).ReturnsAsync(existing); + service.Setup(s => s.GetWorkOrderDetailAsync(UnknownId, It.IsAny())) + .ReturnsAsync((WorkOrderDetailReadModel?)null); + + var controller = new WorkOrderController( + service.Object, + Mock.Of(), + Mock.Of>()) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + }; + return (controller, existing); + } + + [Fact] + public async Task Existing_id_returns_the_same_work_order_through_both_routes() + { + var (controller, existing) = NewController(); + + var byQuery = await controller.GetWorkorderById(ExistingId); + var byRoute = await controller.GetWorkorderByRouteId(ExistingId); + + byQuery.Should().BeOfType().Which.Value.Should().BeSameAs(existing); + byRoute.Should().BeOfType().Which.Value.Should().BeSameAs(existing); + } + + [Fact] + public async Task Unknown_id_keeps_the_not_found_response_through_both_routes() + { + var (controller, _) = NewController(); + + foreach (var result in new[] + { + await controller.GetWorkorderById(UnknownId), + await controller.GetWorkorderByRouteId(UnknownId), + }) + { + var body = result.Should().BeOfType().Which.Value + .Should().BeOfType().Subject; + body.Status.Should().Be("Error"); + body.Message.Should().Be("ID not found!"); + } + } +} diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs index 6411337..1bb17e5 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs @@ -17,8 +17,8 @@ namespace Api.SeaHavenIndustries.Tests; /// combination is registered more than once by different actions. /// /// Routes are read from the ASP.NET Core action descriptor provider so the EXACT templates the -/// framework will dispatch are compared, including multi-attribute actions (e.g. Editworkorder, -/// GetWorkorderById) and the two shared controller-level base routes. +/// framework will dispatch are compared, including multi-attribute actions (e.g. Editworkorder) +/// and the two shared controller-level base routes. /// public class WorkOrderRouteContractTests { @@ -39,7 +39,7 @@ public class WorkOrderRouteContractTests /// WorkOrderController exposed, plus the author-only board-comment edit endpoint (SH-122). /// Every action is reachable under both api/WorkOrder and api/workorders; that base-route /// duplication is collapsed here, so this is the distinct action-relative contract. 52 routes - /// come from 50 actions (Editworkorder and GetWorkorderById each bind two routes). + /// come from 51 actions (Editworkorder binds two routes). /// private static readonly HashSet ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal) { diff --git a/Api.SeaHavenIndustries/Controllers/DashboardController.cs b/Api.SeaHavenIndustries/Controllers/DashboardController.cs index 6955cc2..5551a5d 100644 --- a/Api.SeaHavenIndustries/Controllers/DashboardController.cs +++ b/Api.SeaHavenIndustries/Controllers/DashboardController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using SeaHaven.Services.DTOs; using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers @@ -17,17 +18,155 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpGet("Stats")] - public async Task GetStats(CancellationToken cancellationToken) + public async Task GetStats( + [FromQuery] DashboardStatsQueryDTO query, + CancellationToken cancellationToken) { - var stats = await _dashboardService.GetStatsAsync(cancellationToken); + var stats = await _dashboardService.GetStatsAsync(User, query, cancellationToken); return Ok(new { total = stats.Total, open = stats.Open, notDispatched = stats.NotDispatched, - completed = stats.Completed + completed = stats.Completed, + breakdown = stats.Breakdown, + dueCount = stats.DueCount, + completedDueCount = stats.CompletedDueCount, + completionRate = stats.CompletionRate, + averageResolutionDays = stats.AverageResolutionDays }); } + + [HttpGet("Workload")] + public async Task GetWorkload( + [FromQuery] DashboardStatsQueryDTO query, + [FromQuery] int page = 1, + CancellationToken cancellationToken = default) + { + try + { + return Ok(await _dashboardService.GetWorkloadAsync( + User, query, page, cancellationToken)); + } + catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex) + when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new + { + code = ex.Code, + message = ex.Message + }); + } + catch (ArgumentOutOfRangeException ex) + { + return BadRequest(ex.Message); + } + } + + [HttpGet("Performance")] + public async Task GetPerformance( + [FromQuery] DashboardStatsQueryDTO query, + [FromQuery] int page = 1, + CancellationToken cancellationToken = default) + { + try + { + return Ok(await _dashboardService.GetPerformanceAsync( + User, query, page, cancellationToken)); + } + catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex) + when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new + { + code = ex.Code, + message = ex.Message + }); + } + catch (ArgumentOutOfRangeException ex) + { + return BadRequest(ex.Message); + } + } + + [HttpGet("Regions")] + public async Task GetRegions( + [FromQuery] DashboardStatsQueryDTO query, + CancellationToken cancellationToken = default) + { + try + { + return Ok(await _dashboardService.GetRegionsAsync(User, query, cancellationToken)); + } + catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex) + when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new + { + code = ex.Code, + message = ex.Message + }); + } + } + + [HttpGet("Trend")] + public async Task GetTrend( + [FromQuery] DashboardTrendQueryDTO query, + CancellationToken cancellationToken = default) + { + try + { + var trend = await _dashboardService.GetTrendAsync(User, query, cancellationToken); + + return Ok(new + { + granularity = trend.Granularity, + today = trend.Today, + buckets = trend.Buckets.Select(bucket => new + { + date = bucket.Date, + label = bucket.Label, + total = bucket.Total, + open = bucket.Open, + completed = bucket.Completed, + canceled = bucket.Canceled, + overdue = bucket.Overdue, + isCurrent = bucket.IsCurrent + }) + }); + } + catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex) + when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new + { + code = ex.Code, + message = ex.Message + }); + } + catch (ArgumentOutOfRangeException ex) + { + return BadRequest(ex.Message); + } + } + + [HttpGet("VendorInsights")] + public async Task GetVendorInsights(CancellationToken cancellationToken = default) + { + try + { + return Ok(await _dashboardService.GetVendorInsightsAsync(User, cancellationToken)); + } + catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex) + when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new + { + code = ex.Code, + message = ex.Message + }); + } + } } } diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index c7928de..9c73034 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -208,9 +208,15 @@ namespace Api.SeaHavenIndustries.Controllers } } - [HttpGet("{id:int}")] + // SH-374: two actions, one per route, so each binds id from the source its route provides. + // A single action carrying both routes inferred id as [FromRoute] and the query route got 0. [HttpGet("GetWorkorderById")] - public async Task GetWorkorderById(int id) + public Task GetWorkorderById([FromQuery] int id) => GetWorkorderDetail(id); + + [HttpGet("{id:int}")] + public Task GetWorkorderByRouteId([FromRoute] int id) => GetWorkorderDetail(id); + + private async Task GetWorkorderDetail(int id) { try { diff --git a/SeaHaven.DataServices/Implementation/DashboardDataService.cs b/SeaHaven.DataServices/Implementation/DashboardDataService.cs index d1e29c3..a56f74f 100644 --- a/SeaHaven.DataServices/Implementation/DashboardDataService.cs +++ b/SeaHaven.DataServices/Implementation/DashboardDataService.cs @@ -1,5 +1,7 @@ using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Helpers; using SeaHaven.DataServices.Interfaces; namespace SeaHaven.DataServices.Implementation @@ -13,9 +15,15 @@ namespace SeaHaven.DataServices.Implementation _context = context; } - public async Task GetWorkOrderCountsAsync(CancellationToken cancellationToken) + public async Task GetWorkOrderCountsAsync( + int? accountId, + CancellationToken cancellationToken) { var query = _context.workOrders.Where(w => w.istemplate != true); + if (accountId is int scopedAccountId) + { + query = query.Where(w => w.AccountId == scopedAccountId); + } var total = await query.CountAsync(cancellationToken); var open = await query.CountAsync(w => w.Status == "Open" || w.Status == "In Progress" || w.Status == "On Hold", cancellationToken); @@ -30,5 +38,130 @@ namespace SeaHaven.DataServices.Implementation Completed = completed }; } + + public async Task> GetDashboardWorkOrdersAsync( + int? accountId, + string? dispatcherId, + DateOnly? dateFrom, + DateOnly? dateTo, + CancellationToken cancellationToken) + { + var query = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()); + if (accountId is int scopedAccountId) + query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, scopedAccountId); + + if (dispatcherId != null) + query = query.Where(w => w.AssignTo == dispatcherId); + + if (dateFrom is DateOnly from && dateTo is DateOnly to) + { + var fromDate = from.ToDateTime(TimeOnly.MinValue); + var toDateExclusive = to.AddDays(1).ToDateTime(TimeOnly.MinValue); + query = query.Where(w => + w.ScheduledDate >= fromDate && w.ScheduledDate < toDateExclusive); + } + + return await query + .Select(w => new DashboardWorkOrder( + w.Id, + w.AssignTo, + w.WorkOrderType, + w.LifecycleStatus, + w.LegacyStatus ?? w.Status, + w.ScheduledDate, + w.OriginalDate, + w.CompletedDate, + w.Locations == null ? null : w.Locations.State, + w.RescheduleCount, + w.PrimaryDispatch == null || w.PrimaryDispatch.Vendor == null + ? null + : w.PrimaryDispatch.Vendor.CompanyId, + w.PrimaryDispatch == null || w.PrimaryDispatch.Vendor == null + || w.PrimaryDispatch.Vendor.Company == null + ? null + : w.PrimaryDispatch.Vendor.Company.Name, + w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null + && w.PrimaryDispatch.Vendor.IsActive, + w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null + && w.PrimaryDispatch.Vendor.Company != null + && w.PrimaryDispatch.Vendor.Company.IsDeleted == true)) + .ToListAsync(cancellationToken); + } + + public async Task> GetDispatcherWorkloadAsync( + int? accountId, + string? dispatcherId, + DateOnly? dateFrom, + DateOnly? dateTo, + CancellationToken cancellationToken) + { + var dispatcherUsers = from user in _context.Users.AsNoTracking() + join userRole in _context.UserRoles + on user.Id equals userRole.UserId + join role in _context.Roles + on userRole.RoleId equals role.Id + where user.IsDeleted != true + && (role.Name == "Dispatcher" || role.Name == "DISPATCHER") + && (dispatcherId == null || user.Id == dispatcherId) + select new + { + user.Id, + Name = ((user.FirstName ?? "") + " " + (user.LastName ?? "")).Trim(), + user.Color + }; + + var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()); + if (accountId is int scopedAccountId) + workOrders = WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, scopedAccountId); + + if (dateFrom is DateOnly from && dateTo is DateOnly to) + { + var fromDate = from.ToDateTime(TimeOnly.MinValue); + var toDateExclusive = to.AddDays(1).ToDateTime(TimeOnly.MinValue); + workOrders = workOrders.Where(w => + w.ScheduledDate >= fromDate && w.ScheduledDate < toDateExclusive); + } + + var counts = await workOrders + .Where(w => w.AssignTo != null) + .GroupBy(w => w.AssignTo!) + .Select(group => new + { + DispatcherId = group.Key, + TotalCount = group.Count(), + OpenCount = group.Count(w => + w.LifecycleStatus != LifecycleStatus.Completed + && w.LifecycleStatus != LifecycleStatus.Canceled + && (w.LifecycleStatus != null + || (w.LegacyStatus ?? w.Status) != "Done" + && (w.LegacyStatus ?? w.Status) != "Completed" + && (w.LegacyStatus ?? w.Status) != "Complete" + && (w.LegacyStatus ?? w.Status) != "Closed" + && (w.LegacyStatus ?? w.Status) != "Canceled" + && (w.LegacyStatus ?? w.Status) != "Cancelled")) + }) + .ToListAsync(cancellationToken); + + var dispatcherList = await dispatcherUsers + .Distinct() + .ToListAsync(cancellationToken); + + return dispatcherList + .GroupJoin( + counts, + user => user.Id, + count => count.DispatcherId, + (user, matchingCounts) => + { + var count = matchingCounts.SingleOrDefault(); + return new DashboardDispatcherWorkload( + user.Id, + user.Name, + user.Color, + count?.TotalCount ?? 0, + count?.OpenCount ?? 0); + }) + .ToList(); + } } } diff --git a/SeaHaven.DataServices/Implementation/TeamPermissionOverrideDataService.cs b/SeaHaven.DataServices/Implementation/TeamPermissionOverrideDataService.cs index 3449313..4f1d4ab 100644 --- a/SeaHaven.DataServices/Implementation/TeamPermissionOverrideDataService.cs +++ b/SeaHaven.DataServices/Implementation/TeamPermissionOverrideDataService.cs @@ -72,30 +72,53 @@ public sealed class TeamPermissionOverrideDataService : ITeamPermissionOverrideD CancellationToken cancellationToken) { foreach (var pair in overrides) - { - var existing = await _context.UserPermissionOverrides - .SingleOrDefaultAsync( - permission => permission.UserId == userId - && permission.PermissionKey == pair.Key, - cancellationToken); + await SetOverrideCoreAsync(userId, pair.Key, pair.Value, cancellationToken); + } - if (existing is null) - { - await _context.UserPermissionOverrides.AddAsync( - new UserPermissionOverride - { - UserId = userId, - PermissionKey = pair.Key, - State = pair.Value - }, - cancellationToken); - } - else - { - existing.State = pair.Value; - } + private async Task SetOverrideCoreAsync( + string userId, + string permissionKey, + UserPermissionState state, + CancellationToken cancellationToken) + { + var existing = await _context.UserPermissionOverrides + .SingleOrDefaultAsync( + permission => permission.UserId == userId + && permission.PermissionKey == permissionKey, + cancellationToken); + + if (existing is not null) + { + existing.State = state; + await _context.SaveChangesAsync(cancellationToken); + return; } - await _context.SaveChangesAsync(cancellationToken); + var addition = new UserPermissionOverride + { + UserId = userId, + PermissionKey = permissionKey, + State = state + }; + await _context.UserPermissionOverrides.AddAsync(addition, cancellationToken); + + try + { + await _context.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException) + { + // A concurrent write inserted the same composite key between our + // existence check and this save. Converge on the caller's intent. + _context.Entry(addition).State = EntityState.Detached; + + var winner = await _context.UserPermissionOverrides + .SingleAsync( + permission => permission.UserId == userId + && permission.PermissionKey == permissionKey, + cancellationToken); + winner.State = state; + await _context.SaveChangesAsync(cancellationToken); + } } } diff --git a/SeaHaven.DataServices/Implementation/UserDataService.cs b/SeaHaven.DataServices/Implementation/UserDataService.cs index faafe0e..374d34e 100644 --- a/SeaHaven.DataServices/Implementation/UserDataService.cs +++ b/SeaHaven.DataServices/Implementation/UserDataService.cs @@ -155,6 +155,10 @@ namespace SeaHaven.DataServices.Implementation .Where(role => role.UserId == id) .ExecuteDeleteAsync(cancellationToken); + await _context.UserPermissionOverrides + .Where(permissionOverride => permissionOverride.UserId == id) + .ExecuteDeleteAsync(cancellationToken); + await _context.Users .Where(existingUser => existingUser.Id == id) .ExecuteDeleteAsync(cancellationToken); diff --git a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs index 7acb41f..5348703 100644 --- a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs @@ -1,10 +1,51 @@ namespace SeaHaven.DataServices.Interfaces { + using Data.SeaHavenIndustries.Enums; + public interface IDashboardDataService { - Task GetWorkOrderCountsAsync(CancellationToken cancellationToken); + Task GetWorkOrderCountsAsync( + int? accountId, + CancellationToken cancellationToken); + + Task> GetDashboardWorkOrdersAsync( + int? accountId, + string? dispatcherId, + DateOnly? dateFrom, + DateOnly? dateTo, + CancellationToken cancellationToken); + + Task> GetDispatcherWorkloadAsync( + int? accountId, + string? dispatcherId, + DateOnly? dateFrom, + DateOnly? dateTo, + CancellationToken cancellationToken); } + public sealed record DashboardWorkOrder( + int Id, + string? AssignTo, + WorkOrderType? WorkOrderType, + LifecycleStatus? LifecycleStatus, + string? LegacyStatus, + DateTime? ScheduledDate, + DateOnly? OriginalDate, + DateTime? CompletedDate, + string? LocationState = null, + int RescheduleCount = 0, + int? VendorCompanyId = null, + string? VendorCompanyName = null, + bool VendorIsActive = false, + bool VendorCompanyIsDeleted = false); + + public sealed record DashboardDispatcherWorkload( + string DispatcherId, + string DispatcherName, + string? Color, + int TotalCount, + int OpenCount); + public class DashboardCounts { public int Total { get; set; } diff --git a/SeaHaven.Services/DTOs/DashboardDTOs.cs b/SeaHaven.Services/DTOs/DashboardDTOs.cs index 6bac68c..ef5c70d 100644 --- a/SeaHaven.Services/DTOs/DashboardDTOs.cs +++ b/SeaHaven.Services/DTOs/DashboardDTOs.cs @@ -1,10 +1,118 @@ namespace SeaHaven.Services.DTOs { + public class DashboardStatsQueryDTO + { + public DateOnly? DateFrom { get; set; } + public DateOnly? DateTo { get; set; } + } + + public sealed class DashboardTrendQueryDTO : DashboardStatsQueryDTO + { + public string? Range { get; set; } + public int? Year { get; set; } + } + + public sealed class DashboardTrendResponseDTO + { + public string Granularity { get; init; } = "day"; + public DateOnly Today { get; init; } + public IReadOnlyList Buckets { get; init; } = []; + } + + public sealed class DashboardTrendBucketDTO + { + public DateOnly Date { get; init; } + public string Label { get; init; } = string.Empty; + public int Total { get; init; } + public int Open { get; init; } + public int Completed { get; init; } + public int Canceled { get; init; } + public int Overdue { get; init; } + public bool IsCurrent { get; init; } + } + + public sealed class DashboardWorkloadResponseDTO + { + public IReadOnlyList Items { get; init; } = []; + public int Page { get; init; } + public int PageSize { get; init; } + public int TotalDispatchers { get; init; } + } + + public sealed class DashboardWorkloadRowDTO + { + public string DispatcherId { get; init; } = string.Empty; + public string DispatcherName { get; init; } = string.Empty; + public string? Color { get; init; } + public int TotalCount { get; init; } + public int OpenCount { get; init; } + } + + public sealed class DashboardPerformanceResponseDTO + { + public IReadOnlyList Items { get; init; } = []; + public int Page { get; init; } + public int PageSize { get; init; } + public int TotalDispatchers { get; init; } + } + + public sealed class DashboardPerformanceRowDTO + { + public string DispatcherId { get; init; } = string.Empty; + public string DispatcherName { get; init; } = string.Empty; + public string? Color { get; init; } + public decimal CompletionRate { get; init; } + public decimal? AverageResolutionDays { get; init; } + } + + public sealed class DashboardRegionResponseDTO + { + public IReadOnlyList Items { get; init; } = []; + } + + public sealed class DashboardRegionRowDTO + { + public string Region { get; init; } = string.Empty; + public int WorkOrderCount { get; init; } + } + + public sealed class DashboardVendorInsightsResponseDTO + { + public IReadOnlyList Items { get; init; } = []; + public int Page { get; init; } + public int PageSize { get; init; } + public int TotalVendors { get; init; } + } + + public sealed class DashboardVendorInsightsRowDTO + { + public int VendorCompanyId { get; init; } + public string VendorCompanyName { get; init; } = string.Empty; + public decimal CompletionRate { get; init; } + public decimal RescheduleRate { get; init; } + public decimal? AverageResolutionDays { get; init; } + public int TotalJobs { get; init; } + } + public class DashboardStatsDTO { public int Total { get; set; } public int Open { get; set; } public int NotDispatched { get; set; } public int Completed { get; set; } + public DashboardBreakdownDTO Breakdown { get; set; } = new(); + public int DueCount { get; set; } + public int CompletedDueCount { get; set; } + public decimal CompletionRate { get; set; } + public decimal? AverageResolutionDays { get; set; } + } + + public sealed class DashboardBreakdownDTO + { + public int PM { get; set; } + public int Emergency { get; set; } + public int Reactive { get; set; } + public int Overdue { get; set; } + public int Other { get; set; } } } diff --git a/SeaHaven.Services/Helpers/DashboardBusinessTime.cs b/SeaHaven.Services/Helpers/DashboardBusinessTime.cs new file mode 100644 index 0000000..2ad280d --- /dev/null +++ b/SeaHaven.Services/Helpers/DashboardBusinessTime.cs @@ -0,0 +1,13 @@ +namespace SeaHaven.Services.Helpers; + +public static class DashboardBusinessTime +{ + public const string TimeZoneId = "America/New_York"; + + public static DateOnly Today() + { + var timezone = TimeZoneInfo.FindSystemTimeZoneById(TimeZoneId); + var businessNow = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, timezone); + return DateOnly.FromDateTime(businessNow); + } +} diff --git a/SeaHaven.Services/Helpers/DashboardMetrics.cs b/SeaHaven.Services/Helpers/DashboardMetrics.cs new file mode 100644 index 0000000..e49d921 --- /dev/null +++ b/SeaHaven.Services/Helpers/DashboardMetrics.cs @@ -0,0 +1,113 @@ +using Data.SeaHavenIndustries.Enums; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Helpers; + +public static class DashboardMetrics +{ + public static DashboardMetricResult Calculate( + IReadOnlyList workOrders, + DateOnly today) + { + var breakdown = new DashboardBreakdownDTO(); + var dueCount = 0; + var completedDueCount = 0; + var resolutionDays = new List(); + + foreach (var workOrder in workOrders) + { + var status = workOrder.LifecycleStatus + ?? LifecycleStatusMapper.ParseLifecycleStatus(workOrder.LegacyStatus); + var completed = status == LifecycleStatus.Completed; + var canceled = status == LifecycleStatus.Canceled; + var pastDue = IsPastDue(workOrder, status, today); + + AddBreakdownCount(breakdown, workOrder.WorkOrderType, pastDue); + + var due = completed || (workOrder.ScheduledDate is DateTime scheduled + && DateOnly.FromDateTime(scheduled.Date) <= today); + if (due) + { + dueCount++; + if (completed) + completedDueCount++; + } + + if (completed && workOrder.CompletedDate is DateTime completedDate) + { + var anchor = workOrder.OriginalDate + ?? (workOrder.ScheduledDate is DateTime scheduledDate + ? DateOnly.FromDateTime(scheduledDate.Date) + : null); + if (anchor is DateOnly originalDate) + resolutionDays.Add((decimal)(completedDate.Date - originalDate.ToDateTime(TimeOnly.MinValue)).TotalDays); + } + } + + return new DashboardMetricResult( + workOrders.Count, + workOrders.Count(workOrder => + { + var status = workOrder.LifecycleStatus + ?? LifecycleStatusMapper.ParseLifecycleStatus(workOrder.LegacyStatus); + return status is not LifecycleStatus.Completed and not LifecycleStatus.Canceled; + }), + workOrders.Count(workOrder => string.IsNullOrWhiteSpace(workOrder.AssignTo)), + workOrders.Count(workOrder => + (workOrder.LifecycleStatus ?? LifecycleStatusMapper.ParseLifecycleStatus(workOrder.LegacyStatus)) + == LifecycleStatus.Completed), + breakdown, + dueCount, + completedDueCount, + dueCount == 0 ? 0 : Math.Round((decimal)completedDueCount / dueCount * 100, 2), + resolutionDays.Count == 0 ? null : Math.Round(resolutionDays.Average(), 2)); + } + + private static bool IsPastDue( + DashboardWorkOrder workOrder, + LifecycleStatus? status, + DateOnly today) + => workOrder.ScheduledDate is DateTime scheduled + && DateOnly.FromDateTime(scheduled.Date) < today + && status is not LifecycleStatus.Completed and not LifecycleStatus.Canceled; + + private static void AddBreakdownCount( + DashboardBreakdownDTO breakdown, + WorkOrderType? type, + bool pastDue) + { + if (pastDue) + { + breakdown.Overdue++; + return; + } + + switch (type) + { + case WorkOrderType.PM: + breakdown.PM++; + break; + case WorkOrderType.Emergency: + breakdown.Emergency++; + break; + case WorkOrderType.Reactive: + breakdown.Reactive++; + break; + default: + breakdown.Other++; + break; + } + } +} + +public sealed record DashboardMetricResult( + int Total, + int Open, + int NotDispatched, + int Completed, + DashboardBreakdownDTO Breakdown, + int DueCount, + int CompletedDueCount, + decimal CompletionRate, + decimal? AverageResolutionDays); diff --git a/SeaHaven.Services/Helpers/DashboardRegions.cs b/SeaHaven.Services/Helpers/DashboardRegions.cs new file mode 100644 index 0000000..f3b5fae --- /dev/null +++ b/SeaHaven.Services/Helpers/DashboardRegions.cs @@ -0,0 +1,84 @@ +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.Services.Helpers; + +public static class DashboardRegions +{ + public static readonly IReadOnlyList Names = + ["East", "Central", "West", "California", "Unmapped/Other"]; + + public static string Resolve(string? state) + { + if (string.IsNullOrWhiteSpace(state)) + return "Unmapped/Other"; + + return StateToRegion.TryGetValue(state.Trim().ToUpperInvariant(), out var region) + ? region + : "Unmapped/Other"; + } + + public static IReadOnlyDictionary Count( + IReadOnlyList workOrders) + { + var counts = Names.ToDictionary(name => name, _ => 0, StringComparer.Ordinal); + foreach (var workOrder in workOrders) + counts[Resolve(workOrder.LocationState)]++; + + return counts; + } + + private static readonly IReadOnlyDictionary StateToRegion = + new Dictionary(StringComparer.Ordinal) + { + ["CT"] = "East", + ["DE"] = "East", + ["GA"] = "East", + ["ME"] = "East", + ["MD"] = "East", + ["MA"] = "East", + ["NH"] = "East", + ["NJ"] = "East", + ["NY"] = "East", + ["NC"] = "East", + ["PA"] = "East", + ["RI"] = "East", + ["SC"] = "East", + ["VT"] = "East", + ["VA"] = "East", + ["WV"] = "East", + ["AL"] = "Central", + ["AR"] = "Central", + ["FL"] = "Central", + ["IL"] = "Central", + ["IN"] = "Central", + ["IA"] = "Central", + ["KS"] = "Central", + ["KY"] = "Central", + ["LA"] = "Central", + ["MI"] = "Central", + ["MN"] = "Central", + ["MS"] = "Central", + ["MO"] = "Central", + ["NE"] = "Central", + ["ND"] = "Central", + ["OH"] = "Central", + ["OK"] = "Central", + ["SD"] = "Central", + ["TN"] = "Central", + ["WI"] = "Central", + ["AK"] = "West", + ["AZ"] = "West", + ["CO"] = "West", + ["HI"] = "West", + ["ID"] = "West", + ["MT"] = "West", + ["NV"] = "West", + ["NM"] = "West", + ["OR"] = "West", + ["TX"] = "West", + ["UT"] = "West", + ["WA"] = "West", + ["WY"] = "West", + ["CA"] = "California" + }; +} diff --git a/SeaHaven.Services/Implementation/DashboardService.cs b/SeaHaven.Services/Implementation/DashboardService.cs index 0bec513..56b4c8a 100644 --- a/SeaHaven.Services/Implementation/DashboardService.cs +++ b/SeaHaven.Services/Implementation/DashboardService.cs @@ -1,5 +1,10 @@ +using System.Globalization; +using System.Security.Claims; +using Data.SeaHavenIndustries.Enums; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; namespace SeaHaven.Services.Implementation @@ -7,22 +12,388 @@ namespace SeaHaven.Services.Implementation public class DashboardService : IDashboardService { private readonly IDashboardDataService _dataService; + private readonly IWorkOrderAccountResolver _accountResolver; - public DashboardService(IDashboardDataService dataService) + public DashboardService( + IDashboardDataService dataService, + IWorkOrderAccountResolver accountResolver) { _dataService = dataService; + _accountResolver = accountResolver; } - public async Task GetStatsAsync(CancellationToken cancellationToken) + public async Task GetStatsAsync( + ClaimsPrincipal user, + DashboardStatsQueryDTO query, + CancellationToken cancellationToken) { - var counts = await _dataService.GetWorkOrderCountsAsync(cancellationToken); + var accountId = _accountResolver.ResolveAccountFilter(user); + var dispatcherId = user.IsInRole("Dispatcher") + ? user.FindFirstValue(ClaimTypes.NameIdentifier) + : null; + if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId)) + throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required."); + + var counts = await _dataService.GetWorkOrderCountsAsync(accountId, cancellationToken); + var workOrders = await _dataService.GetDashboardWorkOrdersAsync( + accountId, + dispatcherId, + query.DateFrom, + query.DateTo, + cancellationToken); + var metrics = DashboardMetrics.Calculate(workOrders, DashboardBusinessTime.Today()); + + var useMetrics = query.DateFrom.HasValue && query.DateTo.HasValue || dispatcherId is not null; return new DashboardStatsDTO { - Total = counts.Total, - Open = counts.Open, - NotDispatched = counts.NotDispatched, - Completed = counts.Completed + Total = useMetrics ? metrics.Total : counts.Total, + Open = useMetrics ? metrics.Open : counts.Open, + NotDispatched = useMetrics ? metrics.NotDispatched : counts.NotDispatched, + Completed = useMetrics ? metrics.Completed : counts.Completed, + Breakdown = metrics.Breakdown, + DueCount = metrics.DueCount, + CompletedDueCount = metrics.CompletedDueCount, + CompletionRate = metrics.CompletionRate, + AverageResolutionDays = metrics.AverageResolutionDays + }; + } + + public async Task GetWorkloadAsync( + ClaimsPrincipal user, + DashboardStatsQueryDTO query, + int page, + CancellationToken cancellationToken) + { + if (page < 1) + throw new ArgumentOutOfRangeException(nameof(page)); + + var accountId = _accountResolver.ResolveAccountFilter(user); + var dispatcherId = user.IsInRole("Dispatcher") + ? user.FindFirstValue(ClaimTypes.NameIdentifier) + : null; + if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId)) + throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required."); + + var rows = await _dataService.GetDispatcherWorkloadAsync( + accountId, + dispatcherId, + query.DateFrom, + query.DateTo, + cancellationToken); + var orderedRows = rows + .OrderByDescending(row => row.OpenCount) + .ThenBy(row => row.DispatcherName, StringComparer.OrdinalIgnoreCase) + .ThenBy(row => row.DispatcherId, StringComparer.Ordinal) + .ToList(); + const int pageSize = 10; + + return new DashboardWorkloadResponseDTO + { + Items = orderedRows.Skip((page - 1) * pageSize).Take(pageSize) + .Select(row => new DashboardWorkloadRowDTO + { + DispatcherId = row.DispatcherId, + DispatcherName = row.DispatcherName, + Color = row.Color, + TotalCount = row.TotalCount, + OpenCount = row.OpenCount + }).ToList(), + Page = page, + PageSize = pageSize, + TotalDispatchers = orderedRows.Count + }; + } + + public async Task GetPerformanceAsync( + ClaimsPrincipal user, + DashboardStatsQueryDTO query, + int page, + CancellationToken cancellationToken) + { + if (page < 1) + throw new ArgumentOutOfRangeException(nameof(page)); + + var accountId = _accountResolver.ResolveAccountFilter(user); + var dispatcherId = user.IsInRole("Dispatcher") + ? user.FindFirstValue(ClaimTypes.NameIdentifier) + : null; + if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId)) + throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required."); + + var directory = await _dataService.GetDispatcherWorkloadAsync( + accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken); + var workOrders = await _dataService.GetDashboardWorkOrdersAsync( + accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken); + var today = DashboardBusinessTime.Today(); + var rows = directory.Select(dispatcher => + { + var metrics = DashboardMetrics.Calculate( + workOrders.Where(workOrder => workOrder.AssignTo == dispatcher.DispatcherId).ToList(), + today); + return new DashboardPerformanceRowDTO + { + DispatcherId = dispatcher.DispatcherId, + DispatcherName = dispatcher.DispatcherName, + Color = dispatcher.Color, + CompletionRate = metrics.CompletionRate, + AverageResolutionDays = metrics.AverageResolutionDays + }; + }) + .OrderByDescending(row => row.CompletionRate) + .ThenBy(row => row.DispatcherName, StringComparer.OrdinalIgnoreCase) + .ThenBy(row => row.DispatcherId, StringComparer.Ordinal) + .ToList(); + const int pageSize = 10; + + return new DashboardPerformanceResponseDTO + { + Items = rows.Skip((page - 1) * pageSize).Take(pageSize).ToList(), + Page = page, + PageSize = pageSize, + TotalDispatchers = rows.Count + }; + } + + public async Task GetRegionsAsync( + ClaimsPrincipal user, + DashboardStatsQueryDTO query, + CancellationToken cancellationToken) + { + var accountId = _accountResolver.ResolveAccountFilter(user); + var dispatcherId = user.IsInRole("Dispatcher") + ? user.FindFirstValue(ClaimTypes.NameIdentifier) + : null; + if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId)) + throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required."); + + var workOrders = await _dataService.GetDashboardWorkOrdersAsync( + accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken); + var counts = DashboardRegions.Count(workOrders); + return new DashboardRegionResponseDTO + { + Items = DashboardRegions.Names + .Select(region => new DashboardRegionRowDTO + { + Region = region, + WorkOrderCount = counts[region] + }) + .ToList() + }; + } + + public async Task GetTrendAsync( + ClaimsPrincipal user, + DashboardTrendQueryDTO query, + CancellationToken cancellationToken) + { + var accountId = _accountResolver.ResolveAccountFilter(user); + var dispatcherId = user.IsInRole("Dispatcher") + ? user.FindFirstValue(ClaimTypes.NameIdentifier) + : null; + if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId)) + throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required."); + + var today = DashboardBusinessTime.Today(); + var (windowFrom, windowTo, granularity) = ResolveTrendWindow(query, today); + var firstBucketStart = BucketStart(windowFrom, granularity); + var lastBucketStart = BucketStart(windowTo, granularity); + var lastBucketEnd = BucketEnd(lastBucketStart, granularity); + + var workOrders = await _dataService.GetDashboardWorkOrdersAsync( + accountId, + dispatcherId, + firstBucketStart.AddDays(-1), + lastBucketEnd.AddDays(1), + cancellationToken); + + var buckets = BuildBucketStarts(firstBucketStart, windowTo, granularity) + .Select(start => new TrendBucket(start)) + .ToList(); + var bucketStarts = buckets.Select(bucket => bucket.Start).ToList(); + + foreach (var workOrder in workOrders) + { + if (workOrder.ScheduledDate is not DateTime scheduled) + continue; + var businessDate = DateOnly.FromDateTime(scheduled.Date); + if (businessDate < firstBucketStart || businessDate > lastBucketEnd) + continue; + var bucket = buckets[LocateBucket(bucketStarts, businessDate)]; + var status = workOrder.LifecycleStatus + ?? LifecycleStatusMapper.ParseLifecycleStatus(workOrder.LegacyStatus); + bucket.Total++; + if (status == LifecycleStatus.Completed) + bucket.Completed++; + else if (status == LifecycleStatus.Canceled) + bucket.Canceled++; + else + { + bucket.Open++; + if (businessDate < today) + bucket.Overdue++; + } + } + + DateOnly? currentStart = today < firstBucketStart || today > lastBucketEnd + ? null + : buckets[LocateBucket(bucketStarts, today)].Start; + + return new DashboardTrendResponseDTO + { + Granularity = granularity, + Today = today, + Buckets = buckets.Select(bucket => new DashboardTrendBucketDTO + { + Date = bucket.Start, + Label = bucket.Start.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + Total = bucket.Total, + Open = bucket.Open, + Completed = bucket.Completed, + Canceled = bucket.Canceled, + Overdue = bucket.Overdue, + IsCurrent = bucket.Start == currentStart + }).ToList() + }; + } + + private static (DateOnly From, DateOnly To, string Granularity) ResolveTrendWindow( + DashboardTrendQueryDTO query, + DateOnly today) + { + if (query.Year is int year) + { + if (year < DateOnly.MinValue.Year || year > DateOnly.MaxValue.Year) + throw new ArgumentOutOfRangeException(nameof(query)); + return (new DateOnly(year, 1, 1), new DateOnly(year, 12, 31), "month"); + } + + if (string.Equals(query.Range, ThreeMonthRange, StringComparison.OrdinalIgnoreCase)) + { + var weeklyTo = query.DateTo ?? today; + var weeklyFrom = query.DateFrom ?? weeklyTo.AddMonths(-3).AddDays(1); + if (weeklyFrom > weeklyTo) + throw new ArgumentOutOfRangeException(nameof(query)); + return (weeklyFrom, weeklyTo, "week"); + } + + if (query.DateFrom is DateOnly dailyFrom && query.DateTo is DateOnly dailyTo) + { + if (dailyFrom > dailyTo) + throw new ArgumentOutOfRangeException(nameof(query)); + return (dailyFrom, dailyTo, "day"); + } + + if (query.DateFrom.HasValue != query.DateTo.HasValue) + throw new ArgumentOutOfRangeException(nameof(query)); + + return (today, today, "day"); + } + + private static List BuildBucketStarts(DateOnly from, DateOnly to, string granularity) + { + var starts = new List(); + var current = BucketStart(from, granularity); + while (current <= to) + { + starts.Add(current); + current = granularity switch + { + "week" => current.AddDays(7), + "month" => current.AddMonths(1), + _ => current.AddDays(1) + }; + } + return starts; + } + + private static DateOnly BucketStart(DateOnly date, string granularity) => granularity switch + { + "week" => date.AddDays(-(((int)date.DayOfWeek + 6) % 7)), + "month" => new DateOnly(date.Year, date.Month, 1), + _ => date + }; + + private static DateOnly BucketEnd(DateOnly start, string granularity) => granularity switch + { + "week" => start.AddDays(6), + "month" => start.AddMonths(1).AddDays(-1), + _ => start + }; + + private static int LocateBucket(List starts, DateOnly date) + { + var low = 0; + var high = starts.Count - 1; + while (low < high) + { + var middle = (low + high + 1) / 2; + if (starts[middle] <= date) + low = middle; + else + high = middle - 1; + } + return low; + } + + private const string ThreeMonthRange = "3m"; + + private sealed class TrendBucket(DateOnly start) + { + public DateOnly Start { get; } = start; + public int Total { get; set; } + public int Open { get; set; } + public int Completed { get; set; } + public int Canceled { get; set; } + public int Overdue { get; set; } + } + + public async Task GetVendorInsightsAsync( + ClaimsPrincipal user, + CancellationToken cancellationToken) + { + var accountId = _accountResolver.ResolveAccountFilter(user); + var workOrders = await _dataService.GetDashboardWorkOrdersAsync( + accountId, null, null, null, cancellationToken); + var activeVendorOrders = workOrders + .Where(workOrder => workOrder.VendorCompanyId.HasValue + && workOrder.VendorIsActive + && !workOrder.VendorCompanyIsDeleted) + .ToList(); + var today = DashboardBusinessTime.Today(); + var rows = activeVendorOrders + .GroupBy(workOrder => new + { + Id = workOrder.VendorCompanyId!.Value, + Name = workOrder.VendorCompanyName ?? string.Empty + }) + .Select(group => + { + var orders = group.ToList(); + var metrics = DashboardMetrics.Calculate(orders, today); + var rescheduled = orders.Count(order => order.RescheduleCount >= 1); + return new DashboardVendorInsightsRowDTO + { + VendorCompanyId = group.Key.Id, + VendorCompanyName = group.Key.Name, + CompletionRate = metrics.CompletionRate, + RescheduleRate = Math.Round((decimal)rescheduled / orders.Count * 100, 2), + AverageResolutionDays = metrics.AverageResolutionDays, + TotalJobs = orders.Count + }; + }) + .OrderByDescending(row => row.CompletionRate) + .ThenBy(row => row.VendorCompanyName, StringComparer.OrdinalIgnoreCase) + .ThenBy(row => row.VendorCompanyId) + .ToList(); + const int pageSize = 10; + + return new DashboardVendorInsightsResponseDTO + { + Items = rows.Take(pageSize).ToList(), + Page = 1, + PageSize = pageSize, + TotalVendors = rows.Count }; } } diff --git a/SeaHaven.Services/Interfaces/IDashboardService.cs b/SeaHaven.Services/Interfaces/IDashboardService.cs index bfc2e2d..8464bad 100644 --- a/SeaHaven.Services/Interfaces/IDashboardService.cs +++ b/SeaHaven.Services/Interfaces/IDashboardService.cs @@ -1,9 +1,39 @@ +using System.Security.Claims; using SeaHaven.Services.DTOs; namespace SeaHaven.Services.Interfaces { public interface IDashboardService { - Task GetStatsAsync(CancellationToken cancellationToken); + Task GetStatsAsync( + ClaimsPrincipal user, + DashboardStatsQueryDTO query, + CancellationToken cancellationToken); + + Task GetWorkloadAsync( + ClaimsPrincipal user, + DashboardStatsQueryDTO query, + int page, + CancellationToken cancellationToken); + + Task GetPerformanceAsync( + ClaimsPrincipal user, + DashboardStatsQueryDTO query, + int page, + CancellationToken cancellationToken); + + Task GetRegionsAsync( + ClaimsPrincipal user, + DashboardStatsQueryDTO query, + CancellationToken cancellationToken); + + Task GetTrendAsync( + ClaimsPrincipal user, + DashboardTrendQueryDTO query, + CancellationToken cancellationToken); + + Task GetVendorInsightsAsync( + ClaimsPrincipal user, + CancellationToken cancellationToken); } }