mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
Merge branch 'dev' into feat/ab/sh-210-uplift-decisions
This commit is contained in:
commit
ef371b5917
6 changed files with 347 additions and 34 deletions
|
|
@ -270,11 +270,13 @@ public class DashboardServiceTests
|
||||||
1,
|
1,
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
result.TotalDispatchers.Should().Be(1);
|
result.TotalDispatchers.Should().Be(2);
|
||||||
result.Items.Should().ContainSingle();
|
result.Items.Select(item => item.DispatcherName)
|
||||||
result.Items[0].DispatcherName.Should().Be("Ada One");
|
.Should().ContainInOrder("Ada One", "Ben Two");
|
||||||
result.Items[0].TotalCount.Should().Be(2);
|
result.Items[0].TotalCount.Should().Be(2);
|
||||||
result.Items[0].OpenCount.Should().Be(1);
|
result.Items[0].OpenCount.Should().Be(1);
|
||||||
|
result.Items[1].TotalCount.Should().Be(0);
|
||||||
|
result.Items[1].OpenCount.Should().Be(0);
|
||||||
result.PageSize.Should().Be(10);
|
result.PageSize.Should().Be(10);
|
||||||
|
|
||||||
var adminResult = await NewService(ctx).GetWorkloadAsync(
|
var adminResult = await NewService(ctx).GetWorkloadAsync(
|
||||||
|
|
@ -444,6 +446,275 @@ public class DashboardServiceTests
|
||||||
result.Items[0].RescheduleRate.Should().Be(50);
|
result.Items[0].RescheduleRate.Should().Be(50);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetStatsAsync_DispatcherQuerySelectionCannotBroadenScope()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
ctx.workOrders.AddRange(
|
||||||
|
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" },
|
||||||
|
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" },
|
||||||
|
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" },
|
||||||
|
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" },
|
||||||
|
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" });
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var query = new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" };
|
||||||
|
var stats = await NewService(ctx).GetStatsAsync(
|
||||||
|
AccountUser(1, "dispatcher-1", "Dispatcher"), query, CancellationToken.None);
|
||||||
|
|
||||||
|
stats.Total.Should().Be(2);
|
||||||
|
stats.Open.Should().Be(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetStatsAsync_AdminCanSelectIndividualDispatcherOrMine()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
ctx.workOrders.AddRange(
|
||||||
|
new WorkOrder { AccountId = 1, Status = "Done", AssignTo = "admin-1" },
|
||||||
|
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" },
|
||||||
|
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" },
|
||||||
|
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" });
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var service = NewService(ctx);
|
||||||
|
|
||||||
|
var selected = await service.GetStatsAsync(
|
||||||
|
AccountUser(1, "admin-1"),
|
||||||
|
new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" },
|
||||||
|
CancellationToken.None);
|
||||||
|
selected.Total.Should().Be(3);
|
||||||
|
selected.Open.Should().Be(3);
|
||||||
|
|
||||||
|
var mine = await service.GetStatsAsync(
|
||||||
|
AccountUser(1, "admin-1"),
|
||||||
|
new DashboardStatsQueryDTO { DispatcherId = "mine" },
|
||||||
|
CancellationToken.None);
|
||||||
|
mine.Total.Should().Be(1);
|
||||||
|
mine.Completed.Should().Be(1);
|
||||||
|
|
||||||
|
var all = await service.GetStatsAsync(
|
||||||
|
AccountUser(1, "admin-1"),
|
||||||
|
new DashboardStatsQueryDTO(),
|
||||||
|
CancellationToken.None);
|
||||||
|
all.Total.Should().Be(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetStatsAsync_ManagerSelectionScopesStats()
|
||||||
|
{
|
||||||
|
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, "manager-1", "Manager"),
|
||||||
|
new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" },
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
stats.Total.Should().Be(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetStatsAsync_SchedulerSelectionScopesStats()
|
||||||
|
{
|
||||||
|
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, "scheduler-1", "Scheduler"),
|
||||||
|
new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" },
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
stats.Total.Should().Be(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetDashboardAsync_UnauthorizedRoleFailsClosed()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var service = NewService(ctx);
|
||||||
|
var user = AccountUser(1, "tech-1", "Technician");
|
||||||
|
|
||||||
|
var statsAct = () => service.GetStatsAsync(
|
||||||
|
user, new DashboardStatsQueryDTO(), CancellationToken.None);
|
||||||
|
var statsException = await statsAct.Should().ThrowAsync<WorkOrderBoardValidationException>();
|
||||||
|
statsException.Which.Code.Should().Be("Forbidden");
|
||||||
|
|
||||||
|
var workloadAct = () => service.GetWorkloadAsync(
|
||||||
|
user, new DashboardStatsQueryDTO(), 1, CancellationToken.None);
|
||||||
|
var workloadException = await workloadAct.Should().ThrowAsync<WorkOrderBoardValidationException>();
|
||||||
|
workloadException.Which.Code.Should().Be("Forbidden");
|
||||||
|
|
||||||
|
var performanceAct = () => service.GetPerformanceAsync(
|
||||||
|
user, new DashboardStatsQueryDTO(), 1, CancellationToken.None);
|
||||||
|
var performanceException = await performanceAct.Should().ThrowAsync<WorkOrderBoardValidationException>();
|
||||||
|
performanceException.Which.Code.Should().Be("Forbidden");
|
||||||
|
|
||||||
|
var regionsAct = () => service.GetRegionsAsync(
|
||||||
|
user, new DashboardStatsQueryDTO(), CancellationToken.None);
|
||||||
|
var regionsException = await regionsAct.Should().ThrowAsync<WorkOrderBoardValidationException>();
|
||||||
|
regionsException.Which.Code.Should().Be("Forbidden");
|
||||||
|
|
||||||
|
var trendAct = () => service.GetTrendAsync(
|
||||||
|
user, new DashboardTrendQueryDTO(), CancellationToken.None);
|
||||||
|
var trendException = await trendAct.Should().ThrowAsync<WorkOrderBoardValidationException>();
|
||||||
|
trendException.Which.Code.Should().Be("Forbidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetWorkloadAsync_IndividualSelectionPreservesRosterWithZeroRows()
|
||||||
|
{
|
||||||
|
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<string> { UserId = "dispatcher-1", RoleId = "dispatcher-role" },
|
||||||
|
new IdentityUserRole<string> { 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", Status = "Open" });
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var workload = await NewService(ctx).GetWorkloadAsync(
|
||||||
|
AccountUser(1, "admin-1"),
|
||||||
|
new DashboardStatsQueryDTO { DispatcherId = "dispatcher-1" },
|
||||||
|
1,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
workload.TotalDispatchers.Should().Be(2);
|
||||||
|
workload.Items.Should().HaveCount(2);
|
||||||
|
workload.Items[0].DispatcherId.Should().Be("dispatcher-1");
|
||||||
|
workload.Items[0].TotalCount.Should().Be(2);
|
||||||
|
workload.Items[0].OpenCount.Should().Be(2);
|
||||||
|
workload.Items[1].DispatcherId.Should().Be("dispatcher-2");
|
||||||
|
workload.Items[1].TotalCount.Should().Be(0);
|
||||||
|
workload.Items[1].OpenCount.Should().Be(0);
|
||||||
|
workload.Page.Should().Be(1);
|
||||||
|
workload.PageSize.Should().Be(10);
|
||||||
|
|
||||||
|
var performance = await NewService(ctx).GetPerformanceAsync(
|
||||||
|
AccountUser(1, "admin-1"),
|
||||||
|
new DashboardStatsQueryDTO { DispatcherId = "dispatcher-1" },
|
||||||
|
1,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
performance.TotalDispatchers.Should().Be(2);
|
||||||
|
performance.Items.Should().HaveCount(2);
|
||||||
|
performance.Items[0].DispatcherId.Should().Be("dispatcher-1");
|
||||||
|
performance.Items[0].AssignedCount.Should().Be(2);
|
||||||
|
performance.Items[1].DispatcherId.Should().Be("dispatcher-2");
|
||||||
|
performance.Items[1].AssignedCount.Should().Be(0);
|
||||||
|
performance.Items[1].CompletedCount.Should().Be(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPerformanceAsync_PopulatesAssignedAndCompletedCounts()
|
||||||
|
{
|
||||||
|
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" });
|
||||||
|
ctx.UserRoles.Add(new IdentityUserRole<string>
|
||||||
|
{
|
||||||
|
UserId = "dispatcher-1",
|
||||||
|
RoleId = "dispatcher-role"
|
||||||
|
});
|
||||||
|
ctx.workOrders.AddRange(
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
AssignTo = "dispatcher-1",
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
AssignTo = "dispatcher-1",
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
AssignTo = "dispatcher-1",
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Canceled
|
||||||
|
});
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var result = await NewService(ctx).GetPerformanceAsync(
|
||||||
|
AccountUser(1),
|
||||||
|
new DashboardStatsQueryDTO(),
|
||||||
|
1,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
result.Items.Should().ContainSingle();
|
||||||
|
result.Items[0].AssignedCount.Should().Be(3);
|
||||||
|
result.Items[0].CompletedCount.Should().Be(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetStatsAsync_StatusDistributionBucketsResolvedStatuses()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
ctx.workOrders.AddRange(
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled
|
||||||
|
},
|
||||||
|
new WorkOrder { AccountId = 1, Status = "In Progress" },
|
||||||
|
new WorkOrder { AccountId = 1, Status = "Done" },
|
||||||
|
new WorkOrder { AccountId = 1, Status = "Done" },
|
||||||
|
new WorkOrder { AccountId = 1, Status = "Unparseable" });
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var stats = await NewService(ctx).GetStatsAsync(
|
||||||
|
AccountUser(1, "admin-1"),
|
||||||
|
new DashboardStatsQueryDTO(),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
stats.StatusDistribution.Should().HaveCount(4);
|
||||||
|
stats.StatusDistribution
|
||||||
|
.Select(bucket => (bucket.Status, bucket.Count))
|
||||||
|
.Should().BeEquivalentTo(new[]
|
||||||
|
{
|
||||||
|
(Status: "Completed", Count: 2),
|
||||||
|
(Status: "In Progress", Count: 1),
|
||||||
|
(Status: "Scheduled", Count: 1),
|
||||||
|
(Status: "Unknown", Count: 1)
|
||||||
|
});
|
||||||
|
stats.StatusDistribution.Sum(bucket => bucket.Count).Should().Be(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetStatsAsync_StatusDistributionIsEmptyWithoutWorkOrders()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
|
||||||
|
var stats = await NewService(ctx).GetStatsAsync(
|
||||||
|
AccountUser(1, "admin-1"),
|
||||||
|
new DashboardStatsQueryDTO(),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
stats.StatusDistribution.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetTrendAsync_DailyBucketsClassifyByCalendarScheduledDate()
|
public async Task GetTrendAsync_DailyBucketsClassifyByCalendarScheduledDate()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,8 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
dueCount = stats.DueCount,
|
dueCount = stats.DueCount,
|
||||||
completedDueCount = stats.CompletedDueCount,
|
completedDueCount = stats.CompletedDueCount,
|
||||||
completionRate = stats.CompletionRate,
|
completionRate = stats.CompletionRate,
|
||||||
averageResolutionDays = stats.AverageResolutionDays
|
averageResolutionDays = stats.AverageResolutionDays,
|
||||||
|
statusDistribution = stats.StatusDistribution
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,6 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
on userRole.RoleId equals role.Id
|
on userRole.RoleId equals role.Id
|
||||||
where user.IsDeleted != true
|
where user.IsDeleted != true
|
||||||
&& (role.Name == "Dispatcher" || role.Name == "DISPATCHER")
|
&& (role.Name == "Dispatcher" || role.Name == "DISPATCHER")
|
||||||
&& (dispatcherId == null || user.Id == dispatcherId)
|
|
||||||
select new
|
select new
|
||||||
{
|
{
|
||||||
user.Id,
|
user.Id,
|
||||||
|
|
@ -178,6 +177,9 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
if (accountId is int scopedAccountId)
|
if (accountId is int scopedAccountId)
|
||||||
workOrders = WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, scopedAccountId);
|
workOrders = WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, scopedAccountId);
|
||||||
|
|
||||||
|
if (dispatcherId != null)
|
||||||
|
workOrders = workOrders.Where(w => w.AssignTo == dispatcherId);
|
||||||
|
|
||||||
if (dateFrom is DateOnly from && dateTo is DateOnly to)
|
if (dateFrom is DateOnly from && dateTo is DateOnly to)
|
||||||
{
|
{
|
||||||
var fromDate = from.ToDateTime(TimeOnly.MinValue);
|
var fromDate = from.ToDateTime(TimeOnly.MinValue);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ namespace SeaHaven.Services.DTOs
|
||||||
{
|
{
|
||||||
public DateOnly? DateFrom { get; set; }
|
public DateOnly? DateFrom { get; set; }
|
||||||
public DateOnly? DateTo { get; set; }
|
public DateOnly? DateTo { get; set; }
|
||||||
|
public string? DispatcherId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class DashboardTrendQueryDTO : DashboardStatsQueryDTO
|
public sealed class DashboardTrendQueryDTO : DashboardStatsQueryDTO
|
||||||
|
|
@ -63,6 +64,8 @@ namespace SeaHaven.Services.DTOs
|
||||||
public string? Color { get; init; }
|
public string? Color { get; init; }
|
||||||
public decimal CompletionRate { get; init; }
|
public decimal CompletionRate { get; init; }
|
||||||
public decimal? AverageResolutionDays { get; init; }
|
public decimal? AverageResolutionDays { get; init; }
|
||||||
|
public int AssignedCount { get; init; }
|
||||||
|
public int CompletedCount { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class DashboardRegionResponseDTO
|
public sealed class DashboardRegionResponseDTO
|
||||||
|
|
@ -108,6 +111,13 @@ namespace SeaHaven.Services.DTOs
|
||||||
public int CompletedDueCount { get; set; }
|
public int CompletedDueCount { get; set; }
|
||||||
public decimal CompletionRate { get; set; }
|
public decimal CompletionRate { get; set; }
|
||||||
public decimal? AverageResolutionDays { get; set; }
|
public decimal? AverageResolutionDays { get; set; }
|
||||||
|
public IReadOnlyList<DashboardStatusBucketDTO> StatusDistribution { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DashboardStatusBucketDTO
|
||||||
|
{
|
||||||
|
public string Status { get; init; } = string.Empty;
|
||||||
|
public int Count { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class DashboardBreakdownDTO
|
public sealed class DashboardBreakdownDTO
|
||||||
|
|
|
||||||
|
|
@ -61,9 +61,26 @@ public static class DashboardMetrics
|
||||||
dueCount,
|
dueCount,
|
||||||
completedDueCount,
|
completedDueCount,
|
||||||
dueCount == 0 ? 0 : Math.Round((decimal)completedDueCount / dueCount * 100, 2),
|
dueCount == 0 ? 0 : Math.Round((decimal)completedDueCount / dueCount * 100, 2),
|
||||||
resolutionDays.Count == 0 ? null : Math.Round(resolutionDays.Average(), 2));
|
resolutionDays.Count == 0 ? null : Math.Round(resolutionDays.Average(), 2),
|
||||||
|
BuildStatusDistribution(workOrders));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<DashboardStatusBucketDTO> BuildStatusDistribution(
|
||||||
|
IReadOnlyList<DashboardWorkOrder> workOrders)
|
||||||
|
=> workOrders
|
||||||
|
.GroupBy(workOrder => workOrder.LifecycleStatus
|
||||||
|
?? LifecycleStatusMapper.ParseLifecycleStatus(workOrder.LegacyStatus))
|
||||||
|
.Select(group => new DashboardStatusBucketDTO
|
||||||
|
{
|
||||||
|
Status = group.Key.HasValue
|
||||||
|
? LifecycleStatusMapper.ToFeLabel(group.Key.Value)
|
||||||
|
: "Unknown",
|
||||||
|
Count = group.Count()
|
||||||
|
})
|
||||||
|
.OrderByDescending(bucket => bucket.Count)
|
||||||
|
.ThenBy(bucket => bucket.Status, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
private static bool IsPastDue(
|
private static bool IsPastDue(
|
||||||
DashboardWorkOrder workOrder,
|
DashboardWorkOrder workOrder,
|
||||||
LifecycleStatus? status,
|
LifecycleStatus? status,
|
||||||
|
|
@ -110,4 +127,5 @@ public sealed record DashboardMetricResult(
|
||||||
int DueCount,
|
int DueCount,
|
||||||
int CompletedDueCount,
|
int CompletedDueCount,
|
||||||
decimal CompletionRate,
|
decimal CompletionRate,
|
||||||
decimal? AverageResolutionDays);
|
decimal? AverageResolutionDays,
|
||||||
|
IReadOnlyList<DashboardStatusBucketDTO> StatusDistribution);
|
||||||
|
|
|
||||||
|
|
@ -28,11 +28,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var accountId = _accountResolver.ResolveAccountFilter(user);
|
var accountId = _accountResolver.ResolveAccountFilter(user);
|
||||||
var dispatcherId = user.IsInRole("Dispatcher")
|
var dispatcherId = ResolveDispatcherScope(user, query);
|
||||||
? 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 counts = await _dataService.GetWorkOrderCountsAsync(accountId, cancellationToken);
|
||||||
var kpis = await _dataService.GetKpiCountsAsync(
|
var kpis = await _dataService.GetKpiCountsAsync(
|
||||||
|
|
@ -63,7 +59,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
DueCount = metrics.DueCount,
|
DueCount = metrics.DueCount,
|
||||||
CompletedDueCount = metrics.CompletedDueCount,
|
CompletedDueCount = metrics.CompletedDueCount,
|
||||||
CompletionRate = metrics.CompletionRate,
|
CompletionRate = metrics.CompletionRate,
|
||||||
AverageResolutionDays = metrics.AverageResolutionDays
|
AverageResolutionDays = metrics.AverageResolutionDays,
|
||||||
|
StatusDistribution = metrics.StatusDistribution
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,11 +74,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
throw new ArgumentOutOfRangeException(nameof(page));
|
throw new ArgumentOutOfRangeException(nameof(page));
|
||||||
|
|
||||||
var accountId = _accountResolver.ResolveAccountFilter(user);
|
var accountId = _accountResolver.ResolveAccountFilter(user);
|
||||||
var dispatcherId = user.IsInRole("Dispatcher")
|
var dispatcherId = ResolveDispatcherScope(user, query);
|
||||||
? 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(
|
var rows = await _dataService.GetDispatcherWorkloadAsync(
|
||||||
accountId,
|
accountId,
|
||||||
|
|
@ -123,11 +116,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
throw new ArgumentOutOfRangeException(nameof(page));
|
throw new ArgumentOutOfRangeException(nameof(page));
|
||||||
|
|
||||||
var accountId = _accountResolver.ResolveAccountFilter(user);
|
var accountId = _accountResolver.ResolveAccountFilter(user);
|
||||||
var dispatcherId = user.IsInRole("Dispatcher")
|
var dispatcherId = ResolveDispatcherScope(user, query);
|
||||||
? 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(
|
var directory = await _dataService.GetDispatcherWorkloadAsync(
|
||||||
accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken);
|
accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken);
|
||||||
|
|
@ -145,7 +134,9 @@ namespace SeaHaven.Services.Implementation
|
||||||
DispatcherName = dispatcher.DispatcherName,
|
DispatcherName = dispatcher.DispatcherName,
|
||||||
Color = dispatcher.Color,
|
Color = dispatcher.Color,
|
||||||
CompletionRate = metrics.CompletionRate,
|
CompletionRate = metrics.CompletionRate,
|
||||||
AverageResolutionDays = metrics.AverageResolutionDays
|
AverageResolutionDays = metrics.AverageResolutionDays,
|
||||||
|
AssignedCount = metrics.Total,
|
||||||
|
CompletedCount = metrics.Completed
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.OrderByDescending(row => row.CompletionRate)
|
.OrderByDescending(row => row.CompletionRate)
|
||||||
|
|
@ -169,11 +160,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var accountId = _accountResolver.ResolveAccountFilter(user);
|
var accountId = _accountResolver.ResolveAccountFilter(user);
|
||||||
var dispatcherId = user.IsInRole("Dispatcher")
|
var dispatcherId = ResolveDispatcherScope(user, query);
|
||||||
? 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(
|
var workOrders = await _dataService.GetDashboardWorkOrdersAsync(
|
||||||
accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken);
|
accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken);
|
||||||
|
|
@ -196,11 +183,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var accountId = _accountResolver.ResolveAccountFilter(user);
|
var accountId = _accountResolver.ResolveAccountFilter(user);
|
||||||
var dispatcherId = user.IsInRole("Dispatcher")
|
var dispatcherId = ResolveDispatcherScope(user, query);
|
||||||
? 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 today = DashboardBusinessTime.Today();
|
||||||
var (windowFrom, windowTo, granularity) = ResolveTrendWindow(query, today);
|
var (windowFrom, windowTo, granularity) = ResolveTrendWindow(query, today);
|
||||||
|
|
@ -404,5 +387,33 @@ namespace SeaHaven.Services.Implementation
|
||||||
TotalVendors = rows.Count
|
TotalVendors = rows.Count
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string? ResolveDispatcherScope(ClaimsPrincipal user, DashboardStatsQueryDTO query)
|
||||||
|
{
|
||||||
|
if (user.IsInRole("Dispatcher"))
|
||||||
|
{
|
||||||
|
var dispatcherId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (string.IsNullOrWhiteSpace(dispatcherId))
|
||||||
|
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
|
||||||
|
return dispatcherId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.IsInRole("Admin") || user.IsInRole("Manager") || user.IsInRole("Scheduler"))
|
||||||
|
{
|
||||||
|
var selected = query.DispatcherId;
|
||||||
|
if (string.IsNullOrWhiteSpace(selected))
|
||||||
|
return null;
|
||||||
|
if (selected.Equals("mine", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var identity = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (string.IsNullOrWhiteSpace(identity))
|
||||||
|
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
|
||||||
|
return identity;
|
||||||
|
}
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new WorkOrderBoardValidationException("Forbidden", "Dashboard access requires an authorized role.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue