mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-07 16:19:07 +00:00
Merge c605ceefae into 452d666547
This commit is contained in:
commit
259716de28
11 changed files with 1039 additions and 26 deletions
|
|
@ -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,333 @@ 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<WorkOrderBoardValidationException>();
|
||||
|
||||
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<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", 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<string>
|
||||
{
|
||||
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" },
|
||||
new Locations { Id = 4, State = "indiana" },
|
||||
new Locations { Id = 5, State = "NEW YORK" },
|
||||
new Locations { Id = 6, State = "California" });
|
||||
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 = 4 },
|
||||
new WorkOrder { AccountId = 1, LocationId = 5 },
|
||||
new WorkOrder { AccountId = 1, LocationId = 6 },
|
||||
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");
|
||||
// NY + "NEW YORK" -> East; "indiana" -> Central; "ca" + "California" -> California;
|
||||
// "XX" + unassigned location -> Unmapped/Other. Full-name storage forms must not
|
||||
// fall through to Unmapped/Other (SH-348 review).
|
||||
result.Items.Select(row => row.WorkOrderCount).Should().Equal(2, 1, 0, 2, 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_MapsEveryUsStateAndItsFullNameToACanonicalBucket()
|
||||
{
|
||||
foreach (var code in UsStateCodes.All)
|
||||
{
|
||||
var byCode = DashboardRegions.Resolve(code);
|
||||
byCode.Should().NotBe(
|
||||
"Unmapped/Other",
|
||||
$"state code {code} must belong to a canonical region");
|
||||
|
||||
var fullName = UsStateCodes.ExpandStorageValues(new[] { code })
|
||||
.First(value => value != code);
|
||||
DashboardRegions.Resolve(fullName).Should().Be(
|
||||
byCode,
|
||||
$"the full-name storage form of {code} must resolve to the same region as the code");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,96 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
}
|
||||
|
||||
[HttpGet("Stats")]
|
||||
public async Task<IActionResult> GetStats(CancellationToken cancellationToken)
|
||||
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<DashboardCounts> GetWorkOrderCountsAsync(CancellationToken cancellationToken)
|
||||
public async Task<DashboardCounts> 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,117 @@ namespace SeaHaven.DataServices.Implementation
|
|||
Completed = completed
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DashboardWorkOrder>> 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))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DashboardDispatcherWorkload>> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,46 @@
|
|||
namespace SeaHaven.DataServices.Interfaces
|
||||
{
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
public interface IDashboardDataService
|
||||
{
|
||||
Task<DashboardCounts> GetWorkOrderCountsAsync(CancellationToken cancellationToken);
|
||||
Task<DashboardCounts> GetWorkOrderCountsAsync(
|
||||
int? accountId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<DashboardWorkOrder>> GetDashboardWorkOrdersAsync(
|
||||
int? accountId,
|
||||
string? dispatcherId,
|
||||
DateOnly? dateFrom,
|
||||
DateOnly? dateTo,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<DashboardDispatcherWorkload>> 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);
|
||||
|
||||
public sealed record DashboardDispatcherWorkload(
|
||||
string DispatcherId,
|
||||
string DispatcherName,
|
||||
string? Color,
|
||||
int TotalCount,
|
||||
int OpenCount);
|
||||
|
||||
public class DashboardCounts
|
||||
{
|
||||
public int Total { get; set; }
|
||||
|
|
|
|||
|
|
@ -1,10 +1,75 @@
|
|||
namespace SeaHaven.Services.DTOs
|
||||
{
|
||||
public sealed class DashboardStatsQueryDTO
|
||||
{
|
||||
public DateOnly? DateFrom { get; set; }
|
||||
public DateOnly? DateTo { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DashboardWorkloadResponseDTO
|
||||
{
|
||||
public IReadOnlyList<DashboardWorkloadRowDTO> 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<DashboardPerformanceRowDTO> 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<DashboardRegionRowDTO> Items { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class DashboardRegionRowDTO
|
||||
{
|
||||
public string Region { get; init; } = string.Empty;
|
||||
public int WorkOrderCount { 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; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
SeaHaven.Services/Helpers/DashboardBusinessTime.cs
Normal file
13
SeaHaven.Services/Helpers/DashboardBusinessTime.cs
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
113
SeaHaven.Services/Helpers/DashboardMetrics.cs
Normal file
113
SeaHaven.Services/Helpers/DashboardMetrics.cs
Normal file
|
|
@ -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<DashboardWorkOrder> workOrders,
|
||||
DateOnly today)
|
||||
{
|
||||
var breakdown = new DashboardBreakdownDTO();
|
||||
var dueCount = 0;
|
||||
var completedDueCount = 0;
|
||||
var resolutionDays = new List<decimal>();
|
||||
|
||||
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);
|
||||
85
SeaHaven.Services/Helpers/DashboardRegions.cs
Normal file
85
SeaHaven.Services/Helpers/DashboardRegions.cs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.Services.Helpers;
|
||||
|
||||
public static class DashboardRegions
|
||||
{
|
||||
public static readonly IReadOnlyList<string> Names =
|
||||
["East", "Central", "West", "California", "Unmapped/Other"];
|
||||
|
||||
public static string Resolve(string? state)
|
||||
{
|
||||
var code = UsStateCodes.ToCode(state);
|
||||
if (code is null)
|
||||
return "Unmapped/Other";
|
||||
|
||||
return StateToRegion.TryGetValue(code, out var region)
|
||||
? region
|
||||
: "Unmapped/Other";
|
||||
}
|
||||
|
||||
public static IReadOnlyDictionary<string, int> Count(
|
||||
IReadOnlyList<DashboardWorkOrder> 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<string, string> StateToRegion =
|
||||
new Dictionary<string, string>(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"
|
||||
};
|
||||
}
|
||||
|
|
@ -56,8 +56,29 @@ namespace SeaHaven.Services.Helpers
|
|||
["WY"] = "WYOMING"
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, string> CodesByName =
|
||||
Names.ToDictionary(pair => pair.Value, pair => pair.Key, StringComparer.Ordinal);
|
||||
|
||||
public static IReadOnlySet<string> All { get; } = new HashSet<string>(Names.Keys, StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Normalises a stored state value to its two-letter postal code. Accepts either a
|
||||
/// postal code ("IN") or a full state name ("Indiana"), case-insensitively, matching
|
||||
/// the storage forms produced by <see cref="ExpandStorageValues"/>. Returns null when
|
||||
/// the value is blank or is not a recognised US state.
|
||||
/// </summary>
|
||||
public static string? ToCode(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return null;
|
||||
|
||||
var normalized = value.Trim().ToUpperInvariant();
|
||||
if (All.Contains(normalized))
|
||||
return normalized;
|
||||
|
||||
return CodesByName.TryGetValue(normalized, out var code) ? code : null;
|
||||
}
|
||||
|
||||
public static IReadOnlyCollection<string> ExpandStorageValues(IEnumerable<string> codes)
|
||||
{
|
||||
return codes
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
using System.Security.Claims;
|
||||
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 +10,173 @@ 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<DashboardStatsDTO> GetStatsAsync(CancellationToken cancellationToken)
|
||||
public async Task<DashboardStatsDTO> 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<DashboardWorkloadResponseDTO> 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<DashboardPerformanceResponseDTO> 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<DashboardRegionResponseDTO> 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()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,30 @@
|
|||
using System.Security.Claims;
|
||||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
public interface IDashboardService
|
||||
{
|
||||
Task<DashboardStatsDTO> GetStatsAsync(CancellationToken cancellationToken);
|
||||
Task<DashboardStatsDTO> GetStatsAsync(
|
||||
ClaimsPrincipal user,
|
||||
DashboardStatsQueryDTO query,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<DashboardWorkloadResponseDTO> GetWorkloadAsync(
|
||||
ClaimsPrincipal user,
|
||||
DashboardStatsQueryDTO query,
|
||||
int page,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<DashboardPerformanceResponseDTO> GetPerformanceAsync(
|
||||
ClaimsPrincipal user,
|
||||
DashboardStatsQueryDTO query,
|
||||
int page,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<DashboardRegionResponseDTO> GetRegionsAsync(
|
||||
ClaimsPrincipal user,
|
||||
DashboardStatsQueryDTO query,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue