2026-08-11 10:45:37 -03:00
|
|
|
using System.Security.Claims;
|
2026-09-10 15:46:25 -03:00
|
|
|
using Data.SeaHavenIndustries;
|
2026-06-30 10:09:48 -03:00
|
|
|
using Data.SeaHavenIndustries.Enums;
|
2026-08-20 13:22:12 -03:00
|
|
|
using SeaHaven.DataServices.Helpers;
|
2026-06-30 10:09:48 -03:00
|
|
|
using SeaHaven.DataServices.Interfaces;
|
|
|
|
|
using SeaHaven.Services.DTOs;
|
|
|
|
|
using SeaHaven.Services.Helpers;
|
|
|
|
|
using SeaHaven.Services.Interfaces;
|
|
|
|
|
|
|
|
|
|
namespace SeaHaven.Services.Implementation
|
|
|
|
|
{
|
|
|
|
|
public class WorkOrderBoardService : IWorkOrderBoardService
|
|
|
|
|
{
|
|
|
|
|
private readonly IWorkOrderBoardDataService _boardDataService;
|
2026-08-11 10:45:37 -03:00
|
|
|
private readonly IWorkOrderAccountResolver _accountResolver;
|
2026-06-30 10:09:48 -03:00
|
|
|
|
2026-08-11 10:45:37 -03:00
|
|
|
public WorkOrderBoardService(
|
|
|
|
|
IWorkOrderBoardDataService boardDataService,
|
|
|
|
|
IWorkOrderAccountResolver accountResolver)
|
2026-06-30 10:09:48 -03:00
|
|
|
{
|
|
|
|
|
_boardDataService = boardDataService;
|
2026-08-11 10:45:37 -03:00
|
|
|
_accountResolver = accountResolver;
|
2026-06-30 10:09:48 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<WorkOrderBoardResponseDto> GetBoardAsync(
|
|
|
|
|
WorkOrderBoardQueryDto query,
|
2026-08-11 10:45:37 -03:00
|
|
|
ClaimsPrincipal user,
|
2026-08-24 18:29:18 -03:00
|
|
|
string? currentUserId,
|
|
|
|
|
CancellationToken cancellationToken = default)
|
2026-06-30 10:09:48 -03:00
|
|
|
{
|
2026-08-11 10:45:37 -03:00
|
|
|
var accountId = _accountResolver.ResolveAccountFilter(user);
|
2026-06-30 10:09:48 -03:00
|
|
|
var weekStart = query.WeekStart;
|
|
|
|
|
var weekEnd = query.WeekEnd ?? weekStart.AddDays(4);
|
|
|
|
|
|
2026-07-09 10:06:36 -03:00
|
|
|
var validationError = WorkOrderOperationalWeek.ValidateWeekWindow(weekStart, weekEnd);
|
|
|
|
|
if (validationError != null)
|
|
|
|
|
throw new ArgumentException(validationError);
|
2026-06-30 10:09:48 -03:00
|
|
|
|
|
|
|
|
var dataQuery = new WorkOrderBoardQuery(
|
|
|
|
|
weekStart,
|
|
|
|
|
weekEnd,
|
|
|
|
|
query.Dispatchers,
|
|
|
|
|
query.MyWorkOrders,
|
|
|
|
|
query.Types,
|
2026-07-24 18:12:03 -03:00
|
|
|
query.Overdue,
|
2026-06-30 10:09:48 -03:00
|
|
|
query.Search,
|
2026-08-11 10:45:37 -03:00
|
|
|
currentUserId,
|
|
|
|
|
accountId);
|
2026-06-30 10:09:48 -03:00
|
|
|
|
2026-08-24 18:29:18 -03:00
|
|
|
var result = await _boardDataService.GetBoardRowsAsync(dataQuery, cancellationToken);
|
2026-06-30 10:09:48 -03:00
|
|
|
var utcNow = DateTime.UtcNow;
|
|
|
|
|
|
|
|
|
|
return new WorkOrderBoardResponseDto
|
|
|
|
|
{
|
|
|
|
|
WeekStart = weekStart,
|
|
|
|
|
WeekEnd = weekEnd,
|
|
|
|
|
Counts = new WorkOrderBoardCountsDto
|
|
|
|
|
{
|
|
|
|
|
Total = result.ScheduledTotalBeforeSearch,
|
|
|
|
|
Returned = result.ScheduledRows.Count
|
|
|
|
|
},
|
2026-07-07 11:26:13 -03:00
|
|
|
Scheduled = result.ScheduledRows.Select(r => MapRawRow(r, utcNow)).ToList(),
|
|
|
|
|
Unscheduled = result.UnscheduledRows.Select(r => MapRawRow(r, utcNow)).ToList()
|
2026-06-30 10:09:48 -03:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<IReadOnlyList<DispatcherLookupDto>> GetDispatcherLookupsAsync()
|
|
|
|
|
{
|
|
|
|
|
var rows = await _boardDataService.GetDispatcherLookupsAsync();
|
|
|
|
|
return rows.Select(r => new DispatcherLookupDto
|
|
|
|
|
{
|
|
|
|
|
Id = r.Id,
|
|
|
|
|
Name = FormatName(r.FirstName, r.LastName),
|
|
|
|
|
Initials = r.Initials,
|
|
|
|
|
Color = r.Color
|
|
|
|
|
}).ToList();
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-11 10:45:37 -03:00
|
|
|
public async Task<WorkOrderBoardRowDto?> GetBoardRowAsync(int workOrderId, ClaimsPrincipal user)
|
2026-07-07 11:26:13 -03:00
|
|
|
{
|
2026-08-11 10:45:37 -03:00
|
|
|
var accountId = _accountResolver.ResolveAccountFilter(user);
|
|
|
|
|
var raw = await _boardDataService.GetBoardRowByIdAsync(workOrderId, accountId);
|
2026-07-07 11:26:13 -03:00
|
|
|
return raw == null ? null : MapRawRow(raw, DateTime.UtcNow);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static WorkOrderBoardRowDto MapRawRow(WorkOrderBoardRawRow row, DateTime utcNow)
|
2026-06-30 10:09:48 -03:00
|
|
|
{
|
2026-07-09 10:06:36 -03:00
|
|
|
// Appt column: date prefers dispatch appointment (vendor slot), then WO ScheduledDate.
|
2026-08-31 10:49:54 -03:00
|
|
|
// Time uses WO ScheduledStart/End only. Do not fall back to a date-only dispatch
|
|
|
|
|
// instant (midnight), or clearing apptTime still renders 00:00.
|
|
|
|
|
var apptStart = row.ScheduledStart
|
|
|
|
|
?? (HasClockTime(row.DispatchApptDate) ? row.DispatchApptDate : null);
|
2026-06-30 10:09:48 -03:00
|
|
|
var apptEnd = row.ScheduledEnd;
|
2026-08-20 13:22:12 -03:00
|
|
|
var (vendorId, vendorName) = WorkOrderBoardDispatchAssignment.LiveVendor(
|
|
|
|
|
row.VendorId,
|
|
|
|
|
row.VendorName,
|
|
|
|
|
row.PrimaryDispatchStatus);
|
2026-06-30 10:09:48 -03:00
|
|
|
|
|
|
|
|
return new WorkOrderBoardRowDto
|
|
|
|
|
{
|
|
|
|
|
Id = row.Id,
|
|
|
|
|
WoNumber = row.InternalWONumber,
|
|
|
|
|
RescheduleCount = row.RescheduleCount,
|
|
|
|
|
CarriedOver = row.CarriedOver,
|
2026-07-30 12:20:07 -03:00
|
|
|
IsAddOn = row.IsAddOn,
|
2026-08-24 15:14:31 -03:00
|
|
|
AvetaRequired = row.AvetaRequired,
|
2026-08-25 15:10:45 -03:00
|
|
|
HasAvetaDocument = row.HasAvetaDocument,
|
2026-08-24 15:14:31 -03:00
|
|
|
OriginalDate = row.OriginalDate,
|
|
|
|
|
OriginalWeek = row.OriginalWeek,
|
2026-06-30 10:09:48 -03:00
|
|
|
WorkOrderType = row.WorkOrderType,
|
2026-09-16 18:02:57 -03:00
|
|
|
ServiceId = row.ServiceId,
|
|
|
|
|
ServiceNameSnapshot = row.ServiceNameSnapshot,
|
2026-06-30 10:09:48 -03:00
|
|
|
IsPastDue = WorkOrderDerivedFields.IsPastDue(row.ScheduledDate, row.LifecycleStatus, utcNow),
|
|
|
|
|
SiteCode = row.SiteCode,
|
|
|
|
|
LocationName = row.LocationName,
|
|
|
|
|
PocName = row.PocName,
|
|
|
|
|
PocPhone = row.PocPhone,
|
|
|
|
|
PocNotes = row.PocNotes,
|
2026-09-18 14:30:54 -03:00
|
|
|
PocCustomized = !string.IsNullOrWhiteSpace(row.WoPocName) || !string.IsNullOrWhiteSpace(row.WoPocPhone),
|
2026-09-10 15:46:25 -03:00
|
|
|
FrozenSite = MapFrozenSite(row.FrozenSite),
|
|
|
|
|
FrozenCompany = MapFrozenCompany(row.FrozenCompany),
|
|
|
|
|
FrozenPoc = MapFrozenPoc(row.FrozenPoc),
|
2026-06-30 10:09:48 -03:00
|
|
|
LifecycleStatus = row.LifecycleStatus,
|
|
|
|
|
LifecycleStatusLabel = WorkOrderDerivedFields.GetLifecycleStatusLabel(row.LifecycleStatus),
|
|
|
|
|
LegacyStatus = row.LegacyStatus,
|
|
|
|
|
DispatcherId = row.AssignTo,
|
|
|
|
|
DispatcherName = FormatName(row.DispatcherFirstName, row.DispatcherLastName),
|
|
|
|
|
Initials = row.Initials,
|
|
|
|
|
Color = row.Color,
|
|
|
|
|
DueDate = row.DueDate,
|
|
|
|
|
ScheduledDate = row.ScheduledDate,
|
|
|
|
|
TargetWeek = row.TargetWeek,
|
|
|
|
|
ScheduleWeekOnly = row.ScheduleWeekOnly,
|
|
|
|
|
DayGroup = WorkOrderDerivedFields.GetDayGroup(row.ScheduledDate),
|
2026-08-20 13:22:12 -03:00
|
|
|
VendorId = vendorId,
|
|
|
|
|
VendorName = vendorName,
|
2026-09-29 17:58:52 -03:00
|
|
|
TechnicianAssigned = row.TechnicianAssigned,
|
2026-08-20 13:22:12 -03:00
|
|
|
PrimaryDispatchStatus = row.PrimaryDispatchStatus,
|
2026-06-30 10:09:48 -03:00
|
|
|
TechName = row.TechName,
|
|
|
|
|
TechPhone = row.TechPhone,
|
|
|
|
|
ApptDate = row.DispatchApptDate ?? row.ScheduledDate,
|
|
|
|
|
ApptTime = WorkOrderDerivedFields.FormatApptTime(apptStart, apptEnd),
|
2026-09-16 22:30:32 -03:00
|
|
|
Pm = row.Trade ?? row.ServiceNameSnapshot ?? row.Problem,
|
2026-07-17 10:37:23 -03:00
|
|
|
ServiceNotes = row.ServiceNotes,
|
|
|
|
|
ExtraServices = ParseExtraServices(row.ExtraServices),
|
2026-09-10 15:46:25 -03:00
|
|
|
AdditionalContacts = MapAdditionalContacts(row),
|
2026-06-30 10:09:48 -03:00
|
|
|
DocStatus = row.DocStatus,
|
2026-09-08 21:10:52 -03:00
|
|
|
MediaCount = row.MediaCount,
|
2026-07-31 11:58:22 -03:00
|
|
|
CompletedDate = row.CompletedDate,
|
2026-07-21 09:42:10 -03:00
|
|
|
FlagColor = row.FlagColor,
|
2026-09-25 13:41:19 -03:00
|
|
|
// Overdue never carries a severity. A standalone severity patch can still
|
|
|
|
|
// store one (the board patches severity before type when correcting Overdue
|
|
|
|
|
// to Emergency/Reactive), so the row never returns it for an Overdue WO.
|
|
|
|
|
Severity = row.WorkOrderType == WorkOrderType.Overdue
|
|
|
|
|
? null
|
|
|
|
|
: WorkOrderSeverityRules.ParseLevel(row.Severity),
|
2026-07-07 11:26:13 -03:00
|
|
|
PrimaryDispatchId = row.PrimaryDispatchId,
|
|
|
|
|
RowVersion = row.RowVersion,
|
2026-08-12 09:43:10 -03:00
|
|
|
DispatchRowVersion = row.DispatchRowVersion,
|
2026-08-13 14:49:56 -03:00
|
|
|
PendingUpliftCount = row.PendingUpliftCount,
|
|
|
|
|
UpliftSummary = WorkOrderUpliftContractMapper.MapSummary(row)
|
2026-06-30 10:09:48 -03:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 15:46:25 -03:00
|
|
|
private static List<WorkOrderAdditionalContactDto>? MapAdditionalContacts(
|
|
|
|
|
WorkOrderBoardRawRow row)
|
|
|
|
|
{
|
|
|
|
|
if (row.FrozenPoc == null)
|
|
|
|
|
return WorkOrderAdditionalContactsMapper.ParseJson(row.AdditionalContacts);
|
|
|
|
|
|
|
|
|
|
var additional = row.FrozenPoc.Contacts
|
|
|
|
|
.Skip(1)
|
|
|
|
|
.Where(contact => !string.IsNullOrWhiteSpace(contact.Name)
|
|
|
|
|
|| !string.IsNullOrWhiteSpace(contact.Phone))
|
|
|
|
|
.Select(contact => new WorkOrderAdditionalContactDto(
|
|
|
|
|
contact.Name ?? "",
|
|
|
|
|
contact.Phone ?? "",
|
|
|
|
|
null))
|
|
|
|
|
.ToList();
|
|
|
|
|
return additional.Count > 0 ? additional : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static WorkOrderFrozenSiteDto? MapFrozenSite(WorkOrderFrozenSite? site)
|
|
|
|
|
=> site == null
|
|
|
|
|
? null
|
|
|
|
|
: new WorkOrderFrozenSiteDto
|
|
|
|
|
{
|
|
|
|
|
Code = site.Code,
|
|
|
|
|
Label = site.Label,
|
|
|
|
|
Address = site.Address,
|
|
|
|
|
City = site.City,
|
|
|
|
|
State = site.State,
|
|
|
|
|
Zip = site.Zip,
|
|
|
|
|
Phone = site.Phone,
|
|
|
|
|
Email = site.Email,
|
|
|
|
|
Maps = site.Maps
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private static WorkOrderFrozenCompanyDto? MapFrozenCompany(WorkOrderFrozenCompany? company)
|
|
|
|
|
=> company == null
|
|
|
|
|
? null
|
|
|
|
|
: new WorkOrderFrozenCompanyDto
|
|
|
|
|
{
|
|
|
|
|
Name = company.Name,
|
|
|
|
|
ContactName = company.ContactName,
|
2026-09-29 17:58:52 -03:00
|
|
|
TechnicianAssigned = company.TechnicianAssigned,
|
2026-09-10 15:46:25 -03:00
|
|
|
Address = company.Address,
|
|
|
|
|
City = company.City,
|
|
|
|
|
State = company.State,
|
|
|
|
|
Zip = company.Zip,
|
|
|
|
|
Phone = company.Phone,
|
|
|
|
|
Email = company.Email,
|
|
|
|
|
Maps = company.Maps
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private static WorkOrderFrozenPocDto? MapFrozenPoc(WorkOrderFrozenPoc? poc)
|
|
|
|
|
=> poc == null
|
|
|
|
|
? null
|
|
|
|
|
: new WorkOrderFrozenPocDto
|
|
|
|
|
{
|
|
|
|
|
Notes = poc.Notes,
|
|
|
|
|
Contacts = poc.Contacts
|
|
|
|
|
.Select(contact => new WorkOrderFrozenPocContactDto
|
|
|
|
|
{
|
|
|
|
|
Name = contact.Name,
|
|
|
|
|
Phone = contact.Phone
|
|
|
|
|
})
|
|
|
|
|
.ToList()
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-31 10:49:54 -03:00
|
|
|
private static bool HasClockTime(DateTime? value)
|
|
|
|
|
=> value.HasValue && value.Value.TimeOfDay != TimeSpan.Zero;
|
|
|
|
|
|
2026-06-30 10:09:48 -03:00
|
|
|
private static string FormatName(string? firstName, string? lastName)
|
|
|
|
|
=> $"{firstName ?? ""} {lastName ?? ""}".Trim();
|
2026-07-17 10:37:23 -03:00
|
|
|
|
|
|
|
|
private static List<string>? ParseExtraServices(string? json)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrWhiteSpace(json))
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var list = System.Text.Json.JsonSerializer.Deserialize<List<string>>(json);
|
|
|
|
|
return list is { Count: > 0 } ? list : null;
|
|
|
|
|
}
|
|
|
|
|
catch (System.Text.Json.JsonException)
|
|
|
|
|
{
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-30 10:09:48 -03:00
|
|
|
}
|
|
|
|
|
}
|