using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; using SeaHaven.Services.Interfaces; namespace SeaHaven.Services.Implementation { public class VendorPortalService : IVendorPortalService { private static readonly HashSet AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase) { "application/pdf", "image/jpeg", "image/jpg", "image/png" }; private readonly IVendorPortalTokenService _tokens; private readonly IDispatchDataService _dispatchData; private readonly IUpliftDataService _upliftData; private readonly ICommentDataService _commentData; private readonly IUserDataService _userData; private readonly IEmailSender _emailSender; private readonly IVendorDocumentDataService _documentData; private readonly IVendorDocumentStoragePort _documentStorage; private readonly FrontendOptions _frontendOptions; private readonly ApprovalsOptions _approvalsOptions; private readonly VendorDocumentsOptions _documentOptions; public VendorPortalService( IVendorPortalTokenService tokens, IDispatchDataService dispatchData, IUpliftDataService upliftData, ICommentDataService commentData, IUserDataService userData, IEmailSender emailSender, IVendorDocumentDataService documentData, IVendorDocumentStoragePort documentStorage, IOptions frontendOptions, IOptions approvalsOptions, IOptions documentOptions) { _tokens = tokens; _dispatchData = dispatchData; _upliftData = upliftData; _commentData = commentData; _userData = userData; _emailSender = emailSender; _documentData = documentData; _documentStorage = documentStorage; _frontendOptions = frontendOptions.Value; _approvalsOptions = approvalsOptions.Value; _documentOptions = documentOptions.Value; } public async Task ResolveSessionAsync(string? token, CancellationToken cancellationToken) { return await _tokens.ResolveSessionAsync(token, cancellationToken); } public async Task> ListDispatchesAsync(VendorPortalSession session, string? status, CancellationToken cancellationToken) { var summaries = await _dispatchData.GetVendorDispatchSummariesAsync(session.Id, status, cancellationToken); return summaries.Select(d => new VendorDispatchSummaryDTO { Id = d.Id, DispatchNumber = d.DispatchNumber, PONumber = d.PONumber, Status = d.Status, NTEAmount = d.NTEAmount, ScheduledDate = d.ScheduledDate, CompletedDate = d.CompletedDate, DispatchedAt = d.DispatchedAt, AcknowledgedAt = d.AcknowledgedAt, WorkOrderTitle = d.WorkOrderTitle, InternalWONumber = d.InternalWONumber, LocationName = d.LocationName, LocationCity = d.LocationCity, LocationState = d.LocationState }); } public async Task GetDispatchDetailAsync(VendorPortalSession session, int id, CancellationToken cancellationToken) { var dispatch = await _dispatchData.GetVendorDispatchDetailAsync(id, session.Id, cancellationToken); if (dispatch == null) return null; var checklist = await _dispatchData.GetPortalChecklistAsync(id, cancellationToken); var signoffs = await _dispatchData.GetPortalSignoffsAsync(id, cancellationToken); var rawUplifts = await _upliftData.GetForVendorDispatchAsync(id, cancellationToken); var rawComments = await _commentData.GetVendorViewableForDispatchAsync(id, cancellationToken); var upliftRequests = rawUplifts.Select(u => new PortalUpliftDTO { Id = u.Id, CurrentNTE = u.CurrentNTE, RequestedNTE = u.RequestedNTE, VendorReason = u.VendorReason, Status = u.Status, RequiredTier = u.RequiredTier, RequestedByVendorName = u.RequestedByVendorName, RequestedAt = u.CreatedDate, DecidedAt = u.DecidedAt, DecisionNote = u.DecisionNote, DecidedByName = string.Join(" ", new[] { u.DecidedByFirstName, u.DecidedByLastName } .Where(s => !string.IsNullOrWhiteSpace(s))).Trim() }).ToList(); var comments = rawComments.Select(c => { var hasShocUser = !string.IsNullOrWhiteSpace(c.UserId); var userName = string.Join(" ", new[] { c.UserFirstName, c.UserLastName } .Where(s => !string.IsNullOrWhiteSpace(s))).Trim(); string resolvedType; string resolvedCommenter; if (hasShocUser) { resolvedType = "dispatcher"; resolvedCommenter = !string.IsNullOrWhiteSpace(userName) ? userName : !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter! : "Dispatcher"; } else if (c.CommentType == "customer") { resolvedType = "customer"; resolvedCommenter = !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter! : "Customer"; } else { resolvedType = "vendor"; resolvedCommenter = !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter! : session.CompanyName ?? "Vendor"; } return new PortalCommentDTO { Id = c.Id, Commenttext = c.Commenttext, Commenter = resolvedCommenter, CommentType = resolvedType, CreatedDate = c.CreatedDate }; }).ToList(); return new VendorDispatchDetailDTO { Id = dispatch.Id, DispatchNumber = dispatch.DispatchNumber, PONumber = dispatch.PONumber, Status = dispatch.Status, NTEAmount = dispatch.NTEAmount, Description = dispatch.Description, ScheduledDate = dispatch.ScheduledDate, CompletedDate = dispatch.CompletedDate, DispatchedAt = dispatch.DispatchedAt, AcknowledgedAt = dispatch.AcknowledgedAt, WorkOrder = dispatch.WorkOrder == null ? null : new PortalWorkOrderDTO { Id = dispatch.WorkOrder.Id, InternalWONumber = dispatch.WorkOrder.InternalWONumber, WorkerOrderTitle = dispatch.WorkOrder.WorkerOrderTitle, Description = dispatch.WorkOrder.Description, Priority = dispatch.WorkOrder.Priority, DueDate = dispatch.WorkOrder.DueDate, Trade = dispatch.WorkOrder.Trade, SubTrade = dispatch.WorkOrder.SubTrade, Problem = dispatch.WorkOrder.Problem }, Location = dispatch.WorkOrder?.Locations == null ? null : new PortalLocationDTO { Name = dispatch.WorkOrder.Locations.Name, Address1 = dispatch.WorkOrder.Locations.Address1, City = dispatch.WorkOrder.Locations.City, State = dispatch.WorkOrder.Locations.State, Zip = dispatch.WorkOrder.Locations.Zip }, Checklist = checklist.Select(c => new PortalChecklistItemDTO { Id = c.Id, ItemText = c.ItemText, IsCompleted = c.IsCompleted, CompletedBy = c.CompletedBy, CompletedAt = c.CompletedAt }), Signoffs = signoffs.Select(s => new PortalSignoffDTO { Id = s.Id, SignoffType = s.SignoffType, Name = s.Name, SignatureMethod = s.SignatureMethod, SignedAt = s.SignedAt }), Comments = comments, UpliftRequests = upliftRequests }; } public async Task AcceptDispatchAsync(VendorPortalSession session, int id, CancellationToken cancellationToken) { var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); if (dispatch.Status != "Sent") { throw new InvalidOperationException($"Cannot accept a dispatch with status '{dispatch.Status}'"); } var now = DateTime.UtcNow; dispatch.Status = "Acknowledged"; dispatch.AcknowledgedAt = now; dispatch.LastModificationTime = now; await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog { WorkOrderId = dispatch.WorkOrderId ?? 0, FieldName = $"Dispatch {dispatch.DispatchNumber} Status", OldValue = "Sent", NewValue = "Acknowledged", Action = "vendor_accept", CreatedAt = now }, cancellationToken); await _dispatchData.SaveChangesAsync(cancellationToken); return new AcceptDispatchResultDTO { Id = dispatch.Id, Status = dispatch.Status, AcknowledgedAt = dispatch.AcknowledgedAt }; } public async Task ChangeStatusAsync(VendorPortalSession session, int id, string? to, CancellationToken cancellationToken) { var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); var from = dispatch.Status; if (!IsAllowedVendorTransition(from, to)) { throw new InvalidOperationException($"Transition from '{from}' to '{to}' is not allowed"); } var now = DateTime.UtcNow; dispatch.Status = to; dispatch.LastModificationTime = now; if (to == "Completed") dispatch.CompletedDate = now; await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog { WorkOrderId = dispatch.WorkOrderId ?? 0, FieldName = $"Dispatch {dispatch.DispatchNumber} Status", OldValue = from, NewValue = to, Action = "vendor_status_change", CreatedAt = now }, cancellationToken); await _dispatchData.SaveChangesAsync(cancellationToken); return new ChangeStatusResultDTO { Id = dispatch.Id, Status = dispatch.Status, CompletedDate = dispatch.CompletedDate }; } public async Task RequestCancelAsync(VendorPortalSession session, int id, string? reason, CancellationToken cancellationToken) { var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled") { throw new InvalidOperationException($"Cannot request cancel on a '{dispatch.Status}' dispatch"); } var now = DateTime.UtcNow; var reasonText = string.IsNullOrWhiteSpace(reason) ? "(no reason provided)" : reason!.Trim(); await _commentData.StageAsync(new Comments { DispatchId = id, WorkerOrderId = dispatch.WorkOrderId, Commenter = session.CompanyName, CommentType = "vendor", RecordType = "cancel_request", Commenttext = $"Vendor requested cancellation: {reasonText}", CreatedDate = now }, cancellationToken); await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog { WorkOrderId = dispatch.WorkOrderId ?? 0, FieldName = $"Dispatch {dispatch.DispatchNumber}", OldValue = dispatch.Status, NewValue = "Cancel Requested", Action = "vendor_request_cancel", CreatedAt = now }, cancellationToken); await _dispatchData.SaveChangesAsync(cancellationToken); } public async Task UpdateChecklistItemAsync(VendorPortalSession session, int id, int itemId, bool isCompleted, CancellationToken cancellationToken) { var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled") { throw new InvalidOperationException("Dispatch is locked"); } var item = await _dispatchData.GetChecklistItemForDispatchAsync(itemId, id, cancellationToken); if (item == null) throw new KeyNotFoundException("Checklist item not found"); var now = DateTime.UtcNow; item.IsCompleted = isCompleted; item.CompletedBy = isCompleted ? session.CompanyName : null; item.CompletedAt = isCompleted ? now : null; item.LastModificationTime = now; await _dispatchData.SaveChangesAsync(cancellationToken); return new ChecklistItemResultDTO { Id = item.Id, IsCompleted = item.IsCompleted, CompletedBy = item.CompletedBy, CompletedAt = item.CompletedAt }; } public async Task AddSignoffAsync(VendorPortalSession session, int id, string? signoffType, string? name, string? signature, string? signatureMethod, CancellationToken cancellationToken) { var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); if (dispatch.Status != "In Progress" && dispatch.Status != "Completed") { throw new InvalidOperationException("Signoffs only allowed on In Progress or Completed dispatches"); } if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(signature)) { throw new InvalidOperationException("Name and signature are required"); } var normalizedSignoffType = (signoffType ?? "vendor").Trim().ToLowerInvariant(); if (normalizedSignoffType != "vendor" && normalizedSignoffType != "customer") { throw new InvalidOperationException("signoffType must be 'vendor' or 'customer'"); } var existing = await _dispatchData.HasSignoffTypeAsync(id, normalizedSignoffType); if (existing) { throw new InvalidOperationException($"A {normalizedSignoffType} signoff already exists for this dispatch"); } var now = DateTime.UtcNow; var signoff = new DispatchSignoff { DispatchId = id, SignoffType = normalizedSignoffType, Name = name, Signature = signature, SignatureMethod = signatureMethod ?? "drawn", SignedAt = now, CreatedDate = now }; await _dispatchData.StageSignoffAsync(signoff, cancellationToken); await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog { WorkOrderId = dispatch.WorkOrderId ?? 0, FieldName = $"Dispatch {dispatch.DispatchNumber} Signoff", OldValue = null, NewValue = $"{(normalizedSignoffType == "customer" ? "Customer" : "Vendor")}: {name}", Action = normalizedSignoffType == "customer" ? "customer_signoff" : "vendor_signoff", CreatedAt = now }, cancellationToken); await _dispatchData.SaveChangesAsync(cancellationToken); return new SignoffResultDTO { Id = signoff.Id, SignedAt = signoff.SignedAt }; } public async Task AddCommentAsync(VendorPortalSession session, int id, string? commentText, CancellationToken cancellationToken) { var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); if (string.IsNullOrWhiteSpace(commentText)) { throw new InvalidOperationException("Comment cannot be empty"); } var now = DateTime.UtcNow; var comment = new Comments { DispatchId = id, WorkerOrderId = dispatch.WorkOrderId, Commenter = session.CompanyName, CommentType = "vendor", RecordType = "comment", Commenttext = commentText, CreatedDate = now }; await _commentData.StageAsync(comment, cancellationToken); await _dispatchData.SaveChangesAsync(cancellationToken); return new CommentResultDTO { Id = comment.Id, Commenttext = comment.Commenttext, Commenter = comment.Commenter, CreatedDate = comment.CreatedDate }; } public async Task RequestUpliftAsync(VendorPortalSession session, int id, decimal requestedNTE, string? reason, CancellationToken cancellationToken) { var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled") { throw new InvalidOperationException($"Cannot request uplift on a '{dispatch.Status}' dispatch"); } if (requestedNTE <= 0) { throw new InvalidOperationException("Requested NTE must be greater than zero"); } var current = dispatch.NTEAmount ?? 0m; if (requestedNTE <= current) { throw new InvalidOperationException("Requested NTE must be greater than the current NTE"); } var pendingExists = await _upliftData.HasPendingAsync(id, cancellationToken); if (pendingExists) { throw new InvalidOperationException("A pending uplift request already exists for this dispatch"); } var tier1Max = _approvalsOptions.UpliftTier1MaxUsd ?? 2500m; var delta = requestedNTE - current; var requiredTier = delta > tier1Max ? 2 : 1; var now = DateTime.UtcNow; var req = new DispatchUpliftRequest { DispatchId = id, CurrentNTE = current, RequestedNTE = requestedNTE, VendorReason = string.IsNullOrWhiteSpace(reason) ? null : reason!.Trim(), Status = "Pending", RequiredTier = requiredTier, RequestedByVendorName = session.CompanyName, CreatedDate = now }; await _upliftData.StageAsync(req, cancellationToken); await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog { WorkOrderId = dispatch.WorkOrderId ?? 0, FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift", OldValue = $"${current:F2}", NewValue = $"${requestedNTE:F2}", Action = "uplift_requested", CreatedAt = now }, cancellationToken); await _upliftData.SaveChangesAsync(cancellationToken); await NotifyDispatcherOfUpliftAsync(dispatch, req, session, cancellationToken); return new UpliftRequestResultDTO { Id = req.Id, Status = req.Status, RequiredTier = req.RequiredTier, CurrentNTE = req.CurrentNTE, RequestedNTE = req.RequestedNTE }; } public async Task CancelUpliftRequestAsync(VendorPortalSession session, int id, int requestId, CancellationToken cancellationToken) { var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); var req = await _upliftData.GetByIdAndDispatchAsync(requestId, id, cancellationToken); if (req == null) throw new KeyNotFoundException("Uplift request not found"); if (req.Status != "Pending") { throw new InvalidOperationException($"Cannot cancel a '{req.Status}' uplift request"); } var now = DateTime.UtcNow; req.Status = "Cancelled"; req.DecidedAt = now; req.LastModificationTime = now; await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog { WorkOrderId = dispatch.WorkOrderId ?? 0, FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift", OldValue = "Pending", NewValue = "Cancelled", Action = "uplift_cancelled", CreatedAt = now }, cancellationToken); await _upliftData.SaveChangesAsync(cancellationToken); return new CancelUpliftResultDTO { Id = req.Id, Status = req.Status }; } public async Task UploadCompletionDocumentAsync( VendorPortalSession session, int dispatchId, IFormFile file, CancellationToken cancellationToken) { if (file is null || file.Length == 0) { throw new InvalidOperationException("A completion document file is required."); } if (file.Length > _documentOptions.MaxSizeBytes) { throw new InvalidOperationException("The uploaded file exceeds the maximum allowed size."); } var contentType = (file.ContentType ?? string.Empty).Trim(); if (!AllowedContentTypes.Contains(contentType)) { throw new InvalidOperationException("Only PDF, JPG, and PNG completion documents are accepted."); } using var buffer = new MemoryStream(); await file.CopyToAsync(buffer, cancellationToken); var bytes = buffer.ToArray(); if (!MatchesSignature(contentType, bytes)) { throw new InvalidOperationException("The uploaded file signature does not match its declared content type."); } var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(dispatchId, session.Id, cancellationToken); if (dispatch == null) { throw new KeyNotFoundException("Dispatch not found"); } var latest = await _documentData.GetLatestForDispatchAsync(dispatchId, cancellationToken); var version = (latest?.Version ?? 0) + 1; var storedFileName = $"{dispatchId}_{version}_{Guid.NewGuid():N}{GetSafeExtension(file.FileName)}"; var now = DateTime.UtcNow; var document = new VendorCompletionDocument { VendorId = session.Id, DispatchId = dispatchId, WorkOrderId = dispatch.WorkOrderId ?? 0, OriginalFileName = Path.GetFileName(file.FileName), StoredFileName = storedFileName, ContentType = contentType, SizeBytes = bytes.Length, ScanStatus = "Pending", ReviewStatus = "Processing", Version = version, ReplacesDocumentId = latest?.Id, CreatedDate = now }; await _documentData.AddAsync(document, cancellationToken); await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog { WorkOrderId = dispatch.WorkOrderId ?? 0, DispatchId = dispatchId, FieldName = $"Dispatch {dispatch.DispatchNumber} Completion Document", OldValue = latest == null ? null : $"v{latest.Version}", NewValue = $"v{version}", Action = "vendor_completion_document_uploaded", ActorType = "vendor", CreatedAt = now }, cancellationToken); await _documentData.SaveChangesAsync(cancellationToken); using var stored = new MemoryStream(bytes); await _documentStorage.SaveAsync(session.Id, dispatchId, storedFileName, stored, cancellationToken); return new UploadCompletionDocumentResultDTO { Id = document.Id, Version = document.Version, ScanStatus = document.ScanStatus, ReviewStatus = document.ReviewStatus }; } public async Task DownloadCompletionDocumentAsync( VendorPortalSession session, int dispatchId, int documentId, CancellationToken cancellationToken) { var document = await _documentData.GetForVendorDispatchAsync(documentId, dispatchId, session.Id, cancellationToken); if (document == null) { return DownloadCompletionDocumentResultDTO.NotFound(); } if (!string.Equals(document.ScanStatus, "Passed", StringComparison.OrdinalIgnoreCase)) { return DownloadCompletionDocumentResultDTO.Locked(); } var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(dispatchId, session.Id, cancellationToken); if (dispatch != null && (dispatch.Status == "Verified" || dispatch.Status == "Cancelled")) { return DownloadCompletionDocumentResultDTO.Locked(); } var content = _documentStorage.OpenRead(session.Id, dispatchId, document.StoredFileName); return DownloadCompletionDocumentResultDTO.Ok(content, document.ContentType, document.OriginalFileName); } private static bool MatchesSignature(string contentType, byte[] bytes) { if (bytes.Length == 0) { return false; } if (contentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase)) { return bytes.Length >= 4 && bytes[0] == 0x25 && bytes[1] == 0x50 && bytes[2] == 0x44 && bytes[3] == 0x46; // %PDF } if (contentType.Equals("image/png", StringComparison.OrdinalIgnoreCase)) { return bytes.Length >= 8 && bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47 && bytes[4] == 0x0D && bytes[5] == 0x0A && bytes[6] == 0x1A && bytes[7] == 0x0A; } if (contentType.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase) || contentType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase)) { return bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF; } return false; } private static string GetSafeExtension(string fileName) { var extension = Path.GetExtension(fileName); return string.IsNullOrWhiteSpace(extension) ? string.Empty : extension; } private async Task NotifyDispatcherOfUpliftAsync(Dispatch dispatch, DispatchUpliftRequest req, VendorPortalSession session, CancellationToken cancellationToken) { try { var dispatcherUserId = await _dispatchData.GetDispatchDispatcherUserIdAsync(dispatch.WorkOrderId ?? 0, cancellationToken); if (string.IsNullOrWhiteSpace(dispatcherUserId)) return; var dispatcherEmail = await _userData.GetEmailByIdAsync(dispatcherUserId, cancellationToken); if (string.IsNullOrWhiteSpace(dispatcherEmail)) return; var frontendBase = _frontendOptions.FrontendBaseUrl?.TrimEnd('/') ?? ""; var workOrderLink = dispatch.WorkOrderId.HasValue ? $"{frontendBase}/workorders/{dispatch.WorkOrderId.Value}" : frontendBase; var vendorName = System.Net.WebUtility.HtmlEncode(session.CompanyName ?? "Vendor"); var reason = System.Net.WebUtility.HtmlEncode(req.VendorReason ?? "(no reason provided)"); var dispatchNum = System.Net.WebUtility.HtmlEncode(dispatch.DispatchNumber ?? ""); var tierText = req.RequiredTier == 2 ? "Tier 2 (Manager approval required)" : "Tier 1"; var subject = $"[Uplift Request] {dispatch.DispatchNumber} — {session.CompanyName} requests ${req.RequestedNTE:F2} (was ${req.CurrentNTE ?? 0m:F2})"; var body = $@"

Vendor Uplift Request

Dispatch: {dispatchNum}

Vendor: {vendorName}

Current NTE${req.CurrentNTE ?? 0m:F2}
Requested NTE${req.RequestedNTE:F2}
Delta${(req.RequestedNTE - (req.CurrentNTE ?? 0m)):F2}
Required Approval{tierText}

Vendor Reason

{reason}

"; await _emailSender.SendEmailAsync(dispatcherEmail, subject, body); } catch { } } private static bool IsAllowedVendorTransition(string? from, string? to) { if (from == "Acknowledged" && to == "In Progress") return true; if (from == "In Progress" && to == "Completed") return true; return false; } } }