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 { public class DashboardService : IDashboardService { private readonly IDashboardDataService _dataService; private readonly IWorkOrderAccountResolver _accountResolver; public DashboardService( IDashboardDataService dataService, IWorkOrderAccountResolver accountResolver) { _dataService = dataService; _accountResolver = accountResolver; } public async Task GetStatsAsync( ClaimsPrincipal user, DashboardStatsQueryDTO query, CancellationToken cancellationToken) { var accountId = _accountResolver.ResolveAccountFilter(user); var dispatcherId = ResolveDispatcherScope(user, query); var counts = await _dataService.GetWorkOrderCountsAsync(accountId, cancellationToken); var kpis = await _dataService.GetKpiCountsAsync( accountId, dispatcherId, DashboardBusinessTime.Today(), cancellationToken); var workOrders = await _dataService.GetDashboardWorkOrdersAsync( accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken); var metrics = DashboardMetrics.Calculate(workOrders, DashboardBusinessTime.Today()); var unassigned = await _dataService.CountOpenUnassignedAsync( accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken); var useMetrics = query.DateFrom.HasValue && query.DateTo.HasValue || dispatcherId is not null; return new DashboardStatsDTO { Total = useMetrics ? metrics.Total : counts.Total, Open = useMetrics ? metrics.Open : counts.Open, NotDispatched = useMetrics ? metrics.NotDispatched : counts.NotDispatched, Completed = useMetrics ? metrics.Completed : counts.Completed, ScheduledTomorrow = kpis.ScheduledTomorrow, PendingUplifts = kpis.PendingUplifts, AvetaPending = kpis.AvetaPending, Unassigned = unassigned, Breakdown = metrics.Breakdown, DueCount = metrics.DueCount, CompletedDueCount = metrics.CompletedDueCount, CompletionRate = metrics.CompletionRate, AverageResolutionDays = metrics.AverageResolutionDays, StatusDistribution = metrics.StatusDistribution }; } 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 = ResolveDispatcherScope(user, query); 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 = ResolveDispatcherScope(user, query); 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, AssignedCount = metrics.Total, CompletedCount = metrics.Completed }; }) .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 = ResolveDispatcherScope(user, query); 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 = ResolveDispatcherScope(user, query); 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 }; } 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."); } } }