using AutoMapper; using Microsoft.Extensions.Configuration; using PowderCoating.Shared.Constants; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.Mvc.Rendering; using PowderCoating.Application.DTOs.AI; using PowderCoating.Application.DTOs.Common; using PowderCoating.Application.DTOs.Quote; using PowderCoating.Application.Interfaces; using PowderCoating.Application.Services; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; using PowderCoating.Web.Extensions; using PowderCoating.Web.Helpers; namespace PowderCoating.Web.Controllers; [Authorize(Policy = AppConstants.Policies.CanCreateQuotes)] public class QuotesController : Controller { private readonly IUnitOfWork _unitOfWork; private readonly IMapper _mapper; private readonly IPricingCalculationService _pricingService; private readonly UserManager _userManager; private readonly ILogger _logger; private readonly IPdfService _pdfService; private readonly ITenantContext _tenantContext; private readonly IMeasurementConversionService _measurementService; private readonly ILookupCacheService _lookupCache; private readonly INotificationService _notificationService; private readonly ISubscriptionService _subscriptionService; private readonly IConfiguration _configuration; private readonly IPlatformSettingsService _platformSettings; private readonly IQuotePhotoService _photoService; private readonly IAiQuoteService _aiService; private readonly IWebHostEnvironment _env; private readonly IJobPhotoService _jobPhotoService; private readonly IAiUsageLogger _usageLogger; public QuotesController( IUnitOfWork unitOfWork, IMapper mapper, IPricingCalculationService pricingService, UserManager userManager, ILogger logger, IPdfService pdfService, ITenantContext tenantContext, IMeasurementConversionService measurementService, ILookupCacheService lookupCache, INotificationService notificationService, ISubscriptionService subscriptionService, IConfiguration configuration, IPlatformSettingsService platformSettings, IQuotePhotoService photoService, IAiQuoteService aiService, IWebHostEnvironment env, IJobPhotoService jobPhotoService, IAiUsageLogger usageLogger) { _unitOfWork = unitOfWork; _mapper = mapper; _pricingService = pricingService; _userManager = userManager; _logger = logger; _pdfService = pdfService; _tenantContext = tenantContext; _measurementService = measurementService; _lookupCache = lookupCache; _notificationService = notificationService; _subscriptionService = subscriptionService; _configuration = configuration; _platformSettings = platformSettings; _photoService = photoService; _aiService = aiService; _env = env; _jobPhotoService = jobPhotoService; _usageLogger = usageLogger; } /// /// Displays the paginated, sortable, filterable quotes list. /// Supports filtering by free-text search and/or status. Tag filtering is applied post-query /// because Tags is a comma-separated string column that can't be efficiently queried server-side. /// The parameter lets dashboard links deep-link into a specific /// status bucket by code name (e.g. "DRAFT") without knowing the database ID. /// public async Task Index( string? searchTerm, int? statusFilter, string? statusCode, string? tagFilter, string? sortColumn, string sortDirection = "asc", int pageNumber = 1, int pageSize = 25) { try { // Resolve statusCode → statusFilter ID if provided (e.g. from dashboard links) if (!string.IsNullOrWhiteSpace(statusCode) && !statusFilter.HasValue) { var companyIdForLookup = _tenantContext.GetCurrentCompanyId() ?? 0; var allStatuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyIdForLookup); var match = allStatuses.FirstOrDefault(s => s.StatusCode.Equals(statusCode.Trim().ToUpper(), StringComparison.OrdinalIgnoreCase)); if (match != null) statusFilter = match.Id; } // Create and validate grid request var gridRequest = new GridRequest { PageNumber = pageNumber, PageSize = pageSize, SortColumn = sortColumn ?? "CreatedAt", SortDirection = sortColumn == null ? "desc" : sortDirection, // Default to desc for CreatedAt SearchTerm = searchTerm }; gridRequest.Validate(); // Build search and status filter System.Linq.Expressions.Expression>? filter = null; if (!string.IsNullOrWhiteSpace(searchTerm) && statusFilter.HasValue) { // Both search and status filter var search = searchTerm.ToLower(); var statusId = statusFilter.Value; filter = q => (q.QuoteNumber.ToLower().Contains(search) || (q.Description != null && q.Description.ToLower().Contains(search)) || (q.Notes != null && q.Notes.ToLower().Contains(search)) || (q.ProspectCompanyName != null && q.ProspectCompanyName.ToLower().Contains(search)) || (q.ProspectContactName != null && q.ProspectContactName.ToLower().Contains(search)) || (q.ProspectEmail != null && q.ProspectEmail.ToLower().Contains(search)) || (q.Customer != null && q.Customer.CompanyName.ToLower().Contains(search))) && q.QuoteStatusId == statusId; } else if (!string.IsNullOrWhiteSpace(searchTerm)) { // Search only var search = searchTerm.ToLower(); filter = q => q.QuoteNumber.ToLower().Contains(search) || (q.Description != null && q.Description.ToLower().Contains(search)) || (q.Notes != null && q.Notes.ToLower().Contains(search)) || (q.ProspectCompanyName != null && q.ProspectCompanyName.ToLower().Contains(search)) || (q.ProspectContactName != null && q.ProspectContactName.ToLower().Contains(search)) || (q.ProspectEmail != null && q.ProspectEmail.ToLower().Contains(search)) || (q.Customer != null && q.Customer.CompanyName.ToLower().Contains(search)); } else if (statusFilter.HasValue) { // Status filter only var statusId = statusFilter.Value; filter = q => q.QuoteStatusId == statusId; } // Build orderBy function Func, IOrderedQueryable> orderBy = gridRequest.SortColumn switch { "QuoteNumber" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(x => x.QuoteNumber) : q.OrderByDescending(x => x.QuoteNumber), "Status" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(x => x.QuoteStatus.DisplayOrder) : q.OrderByDescending(x => x.QuoteStatus.DisplayOrder), "QuoteDate" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(x => x.QuoteDate) : q.OrderByDescending(x => x.QuoteDate), "ExpirationDate" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(x => x.ExpirationDate) : q.OrderByDescending(x => x.ExpirationDate), "Total" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(x => x.Total) : q.OrderByDescending(x => x.Total), "CreatedAt" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(x => x.CreatedAt) : q.OrderByDescending(x => x.CreatedAt), _ => q => q.OrderByDescending(x => x.CreatedAt) }; // Get paged data with Customer and QuoteStatus eager loading var (items, totalCount) = await _unitOfWork.Quotes.GetPagedAsync( gridRequest.PageNumber, gridRequest.PageSize, filter, orderBy, q => q.Customer, q => q.QuoteStatus); // Map to DTOs using AutoMapper var quoteDtos = _mapper.Map>(items); // Apply tag filter (post-query, since Tags is a comma-separated string) if (!string.IsNullOrWhiteSpace(tagFilter)) { var tagLower = tagFilter.Trim().ToLower(); quoteDtos = quoteDtos.Where(q => q.Tags != null && q.Tags.Split(',', StringSplitOptions.RemoveEmptyEntries) .Select(t => t.Trim().ToLower()) .Contains(tagLower)).ToList(); } // Create paged result var pagedResult = new PagedResult { Items = quoteDtos, PageNumber = gridRequest.PageNumber, PageSize = gridRequest.PageSize, TotalCount = string.IsNullOrWhiteSpace(tagFilter) ? totalCount : quoteDtos.Count }; // Set ViewBag for sorting and filters ViewBag.SearchTerm = searchTerm; ViewBag.StatusFilter = statusFilter; ViewBag.StatusCode = statusCode; ViewBag.TagFilter = tagFilter; ViewBag.SortColumn = gridRequest.SortColumn; ViewBag.SortDirection = gridRequest.SortDirection; // Use cached quote statuses var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var quoteStatuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId); ViewBag.QuoteStatuses = quoteStatuses .OrderBy(s => s.DisplayOrder) .Select(s => new SelectListItem { Value = s.Id.ToString(), Text = s.DisplayName, Selected = s.Id == statusFilter }); // Aggregate stats — computed over ALL quotes (not just current page) so stat // cards always reflect the full dataset regardless of current page or page size. var draftSentIds = quoteStatuses .Where(s => s.StatusCode == "DRAFT" || s.StatusCode == "SENT") .Select(s => s.Id).ToList(); var approvedConvertedIds = quoteStatuses .Where(s => s.StatusCode == "APPROVED" || s.StatusCode == "CONVERTED") .Select(s => s.Id).ToList(); var indexStats = await _unitOfWork.Quotes.GetIndexStatsAsync(draftSentIds, approvedConvertedIds); ViewBag.StatOpenCount = indexStats.OpenCount; ViewBag.StatApprovedCount = indexStats.ApprovedConvertedCount; ViewBag.StatTotalValue = indexStats.TotalValue; // Calibration nudge — suppress when named blast setups exist OR legacy CFM is set var costs = (await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == companyId)).FirstOrDefault(); var hasNamedSetups = (await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive)).Any(); ViewBag.QuotingNotCalibrated = costs != null && !hasNamedSetups && costs.CompressorCfm == 0 && costs.BlastRateSqFtPerHourOverride == null; return View(pagedResult); } catch (Exception ex) { _logger.LogError(ex, "Error retrieving quotes"); TempData["Error"] = "An error occurred while loading quotes."; return View(new PagedResult()); } } /// /// Shows the full quote detail view including all line items, coats, prep services, pricing /// breakdown, change history, attached photos, and any deposits recorded against the quote. /// Pricing breakdown is read from the stored snapshot on the Quote row — it is NEVER /// recalculated on load. This keeps the view consistent with what was actually presented to /// the customer even if operating costs have changed since the quote was created. /// Also verifies that ConvertedToJobId still points to a live job (clears stale references). /// public async Task Details(int? id, string? guidedActivation = null) { if (id == null) { return NotFound(); } try { // Load quote with all navigations needed for the Details view var quote = await _unitOfWork.Quotes.LoadForDetailsAsync(id.Value); if (quote == null) { return NotFound(); } var quoteDto = _mapper.Map(quote); // Get customer info if exists if (quote.CustomerId.HasValue) { var customer = await _unitOfWork.Customers.GetByIdAsync(quote.CustomerId.Value); if (customer != null) { // For commercial customers, use company name; for individuals, use contact name quoteDto.CustomerName = !string.IsNullOrWhiteSpace(customer.CompanyName) ? customer.CompanyName : $"{customer.ContactFirstName} {customer.ContactLastName}".Trim(); } } // Populate conversion tracking - verify the job still exists if (quote.ConvertedToJobId.HasValue) { var linkedJob = await _unitOfWork.Jobs.GetByIdAsync(quote.ConvertedToJobId.Value); if (linkedJob == null) { // Job was deleted, reset the quote quote.ConvertedToJobId = null; quote.ConvertedDate = null; await _unitOfWork.Quotes.UpdateAsync(quote); await _unitOfWork.CompleteAsync(); _logger.LogWarning("Quote {QuoteId} had reference to deleted job {JobId}, cleared the reference", quote.Id, quote.ConvertedToJobId); quoteDto.ConvertedToJobId = null; } else { quoteDto.ConvertedToJobId = quote.ConvertedToJobId; } } else { quoteDto.ConvertedToJobId = null; } // Get prepared by user info if (!string.IsNullOrEmpty(quote.PreparedById)) { var preparedBy = await _userManager.FindByIdAsync(quote.PreparedById); if (preparedBy != null) { quoteDto.PreparedByName = $"{preparedBy.FirstName} {preparedBy.LastName}"; } } quoteDto.QuoteItems = _mapper.Map>(quote.QuoteItems); // DEBUG: Log coat data _logger.LogInformation($"=== DETAILS VIEW: Quote {id} has {quoteDto.QuoteItems.Count} items ==="); foreach (var item in quoteDto.QuoteItems) { _logger.LogInformation($"Item '{item.Description}': Has {item.Coats?.Count ?? 0} coats"); if (item.Coats != null) { foreach (var coat in item.Coats) { _logger.LogInformation($" - Coat: {coat.CoatName}, InventoryItem: {coat.InventoryItemName}"); } } } // Get operating costs for debugging var currentUser = await _userManager.GetUserAsync(User); var operatingCosts = await _pricingService.GetOperatingCostsAsync(currentUser!.CompanyId); ViewBag.OperatingCosts = operatingCosts; // Get customer pricing tier if applicable if (quote.CustomerId.HasValue) { var customer = await _unitOfWork.Customers.GetByIdAsync(quote.CustomerId.Value); if (customer?.PricingTierId.HasValue == true) { var pricingTier = await _unitOfWork.PricingTiers.GetByIdAsync(customer.PricingTierId.Value); ViewBag.PricingTier = pricingTier; } } // Build pricing breakdown from stored snapshot values — never recalculate on load if (quote.Total > 0) { quoteDto.PricingBreakdown = new QuotePricingBreakdownDto { MaterialCosts = quote.MaterialCosts, LaborCosts = quote.LaborCosts, EquipmentCosts = quote.EquipmentCosts, ItemsSubtotal = quote.ItemsSubtotal, OvenBatchCost = quote.OvenBatchCost, OvenBatches = quote.OvenBatches, OvenCycleMinutes = quote.OvenCycleMinutes ?? 0, ShopSuppliesAmount = quote.ShopSuppliesAmount, ShopSuppliesPercent = quote.ShopSuppliesPercent, OverheadCosts = quote.OverheadAmount, OverheadPercent = quote.OverheadPercent, ProfitMargin = quote.ProfitMargin, ProfitPercent = quote.ProfitPercent, SubtotalBeforeDiscount = quote.SubTotal, DiscountAmount = quote.DiscountAmount, DiscountPercent = quote.DiscountPercent, SubtotalAfterDiscount = quote.SubTotal - quote.DiscountAmount, RushFee = quote.RushFee, TaxPercent = quote.TaxPercent, TaxAmount = quote.TaxAmount, Total = quote.Total }; } // Load change history var changeHistories = await _unitOfWork.Quotes.GetChangeHistoryAsync(id.Value); var changeHistoryDtos = _mapper.Map>(changeHistories); ViewBag.ChangeHistory = changeHistoryDtos; // Load quote photos (AI + general) var quotePhotos = await _unitOfWork.QuotePhotos.FindAsync(p => p.QuoteId == id.Value); ViewBag.QuotePhotos = quotePhotos.OrderBy(p => p.CreatedAt).ToList(); // Quote photo subscription limits var photoCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0; ViewBag.CanUploadQuotePhoto = await _subscriptionService.CanAddQuotePhotoAsync(photoCompanyId, id.Value); var (photoUsed, photoMax) = await _subscriptionService.GetQuotePhotoCountAsync(photoCompanyId, id.Value); ViewBag.QuotePhotoUsed = photoUsed; ViewBag.QuotePhotoMax = photoMax; // Set measurement units for view var useMetric = await _tenantContext.UseMetricSystemAsync(); ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric); ViewBag.CoverageUnit = _measurementService.GetCoverageUnitLabel(useMetric); var currentUserDetails = await _userManager.GetUserAsync(User); if (currentUserDetails != null) await PopulateEmailNotificationDefaultsAsync(currentUserDetails.CompanyId); // Load deposits recorded against this quote var quoteDeposits = (await _unitOfWork.Deposits.FindAsync(d => d.QuoteId == id.Value, false, d => d.RecordedBy)) .OrderByDescending(d => d.ReceivedDate) .ToList(); ViewBag.Deposits = quoteDeposits; // Customer list for inline customer-change dropdown var allCustomers = await _unitOfWork.Customers.GetAllAsync(); ViewBag.CustomerSelectList = allCustomers .Where(c => c.IsActive) .Select(c => new SelectListItem { Value = c.Id.ToString(), Text = !string.IsNullOrWhiteSpace(c.CompanyName) ? c.CompanyName : $"{c.ContactFirstName} {c.ContactLastName}".Trim() }) .OrderBy(c => c.Text) .ToList(); var quotePrefs = await GetCompanyPreferencesAsync(currentUser!.CompanyId); if (guidedActivation == AppConstants.GuidedActivation.QuoteCreatedStep && quotePrefs?.OnboardingPath == AppConstants.GuidedActivation.QuoteFirstPath && quotePrefs.FirstWorkflowCompleted == false) { ViewBag.GuidedActivationMode = AppConstants.GuidedActivation.QuoteFirstPath; ViewBag.GuidedActivationCallout = new Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel { Show = true, Title = "This is the quote you would send to your customer.", Message = "Next, convert it into a job so it moves into your real production workflow.", ActionText = "Convert to Job" }; } return View(quoteDto); } catch (Exception ex) { _logger.LogError(ex, "Error retrieving quote details for ID {QuoteId}", id); TempData["Error"] = "An error occurred while loading the quote details."; return RedirectToAction(nameof(Index)); } } /// /// Reassigns a quote to a different customer. Clears any prospect fields so the /// quote is treated as a real-customer quote after reassignment. /// [HttpPost] [ValidateAntiForgeryToken] public async Task ChangeCustomer(int id, int customerId) { var quote = await _unitOfWork.Quotes.GetByIdAsync(id); if (quote == null) return NotFound(); var customer = await _unitOfWork.Customers.GetByIdAsync(customerId); if (customer == null) return Json(new { success = false, error = "Customer not found." }); quote.CustomerId = customerId; quote.ProspectCompanyName = null; quote.ProspectContactName = null; quote.ProspectEmail = null; quote.ProspectPhone = null; quote.ProspectAddress = null; quote.ProspectCity = null; quote.ProspectState = null; quote.ProspectZipCode = null; await _unitOfWork.CompleteAsync(); var customerName = !string.IsNullOrWhiteSpace(customer.CompanyName) ? customer.CompanyName : $"{customer.ContactFirstName} {customer.ContactLastName}".Trim(); return Json(new { success = true, customerName, customerId = customer.Id }); } /// /// Generates and streams the quote PDF. /// When is true the browser displays it in a viewer tab; /// when false the browser triggers a file download. The inline path is used by the /// "Preview PDF" button; the download path is used by the email attachment flow. /// PDF generation is delegated to which assembles /// all line items, coats, and pricing data before calling QuestPDF. /// public async Task DownloadPdf(int? id, bool inline = false) { if (id == null) { return NotFound(); } try { // Load quote var quote = await _unitOfWork.Quotes.GetByIdAsync(id.Value); if (quote == null) { return NotFound(); } var quoteDto = _mapper.Map(quote); // Get customer info if exists if (quote.CustomerId.HasValue) { var customer = await _unitOfWork.Customers.GetByIdAsync(quote.CustomerId.Value); if (customer != null) { quoteDto.CustomerCompanyName = customer.CompanyName; quoteDto.CustomerContactFirstName = customer.ContactFirstName; quoteDto.CustomerContactLastName = customer.ContactLastName; quoteDto.CustomerEmail = customer.Email; quoteDto.CustomerPhone = customer.Phone; } } var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(id.Value); quoteDto.QuoteItems = _mapper.Map>(quoteItems); // Get company info and logo var currentUser = await _userManager.GetUserAsync(User); // Populate pricing breakdown from stored snapshot values — never recalculate on load if (quote.Total > 0) { quoteDto.PricingBreakdown = new QuotePricingBreakdownDto { MaterialCosts = quote.MaterialCosts, LaborCosts = quote.LaborCosts, EquipmentCosts = quote.EquipmentCosts, ItemsSubtotal = quote.ItemsSubtotal, OvenBatchCost = quote.OvenBatchCost, OvenBatches = quote.OvenBatches, OvenCycleMinutes = quote.OvenCycleMinutes ?? 0, ShopSuppliesAmount = quote.ShopSuppliesAmount, ShopSuppliesPercent = quote.ShopSuppliesPercent, OverheadCosts = quote.OverheadAmount, OverheadPercent = quote.OverheadPercent, ProfitMargin = quote.ProfitMargin, ProfitPercent = quote.ProfitPercent, SubtotalBeforeDiscount = quote.SubTotal, DiscountAmount = quote.DiscountAmount, DiscountPercent = quote.DiscountPercent, SubtotalAfterDiscount = quote.SubTotal - quote.DiscountAmount, RushFee = quote.RushFee, TaxPercent = quote.TaxPercent, TaxAmount = quote.TaxAmount, Total = quote.Total }; } if (currentUser?.CompanyId == null) { TempData["Error"] = "Company information not found."; return RedirectToAction(nameof(Details), new { id }); } var company = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId); if (company == null) { TempData["Error"] = "Company information not found."; return RedirectToAction(nameof(Details), new { id }); } var companyInfo = new Application.DTOs.Company.CompanyInfoDto { CompanyName = company.CompanyName, Phone = company.Phone, Address = company.Address, City = company.City, State = company.State, ZipCode = company.ZipCode, PrimaryContactEmail = company.PrimaryContactEmail }; // Load company preferences for PDF template settings var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync( p => p.CompanyId == currentUser.CompanyId && !p.IsDeleted, ignoreQueryFilters: true); var template = new Application.DTOs.Company.QuoteTemplateSettingsDto { AccentColor = prefs?.QtAccentColor ?? "#374151", FooterNote = prefs?.QtFooterNote, DefaultTerms = prefs?.QtDefaultTerms }; // Generate PDF var pdfBytes = await _pdfService.GenerateQuotePdfAsync( quoteDto, company.LogoData, company.LogoContentType, companyInfo, template: template ); // Return PDF file var fileName = $"Quote-{quote.QuoteNumber}.pdf"; if (inline) { // Return PDF for inline viewing (for printing) Response.Headers.Append("Content-Disposition", $"inline; filename=\"{fileName}\""); return File(pdfBytes, "application/pdf"); } else { // Return PDF as download return File(pdfBytes, "application/pdf", fileName); } } catch (Exception ex) { _logger.LogError(ex, "Error generating PDF for quote {QuoteId}", id); TempData["Error"] = "An error occurred while generating the PDF. Please try again."; return RedirectToAction(nameof(Details), new { id }); } } /// /// Renders the quote creation wizard. /// Checks the subscription quote limit before rendering — if the tenant has hit their plan cap /// the user sees a friendly upgrade prompt rather than a broken form. /// Optionally pre-selects a customer when is provided (e.g. when /// navigating from the Customer Details page using the "New Quote" shortcut). /// public async Task Create(int? customerId, string? guidedActivation = null) { try { // Subscription limit check — fail fast before rendering the form var currentUser = await _userManager.GetUserAsync(User); if (currentUser != null && !await _subscriptionService.CanAddQuoteAsync(currentUser.CompanyId)) { var (used, max) = await _subscriptionService.GetQuoteCountAsync(currentUser.CompanyId); TempData["Error"] = $"You have reached your plan limit of {max} quotes this month. " + "Your limit resets on the 1st of next month, or you can upgrade your plan."; return RedirectToAction(nameof(Index)); } // Get current user and operating costs to set defaults var operatingCosts = await _pricingService.GetOperatingCostsAsync(currentUser!.CompanyId); // Validate that required operating costs are configured if (operatingCosts == null || operatingCosts.StandardLaborRate <= 0 || operatingCosts.GeneralMarkupPercentage <= 0) { var missingFields = new List(); if (operatingCosts == null || operatingCosts.StandardLaborRate <= 0) missingFields.Add("Standard Labor Rate"); if (operatingCosts == null || operatingCosts.GeneralMarkupPercentage <= 0) missingFields.Add("General Markup"); TempData["Error"] = $"Before creating a quote, please configure the following required operating costs on the Company Settings page: {string.Join(", ", missingFields)}. " + "Click the link below to set these values."; TempData["OperatingCostsLink"] = Url.Action("Index", "CompanySettings") + "#operating-costs"; return RedirectToAction(nameof(Index)); } await PopulateDropDownsAsync(currentUser!.CompanyId, operatingCosts.OvenOperatingCostPerHour); await SetMeasurementViewBagAsync(); await SetDefaultTermsAsync(currentUser.CompanyId); // Check if the pre-selected customer is tax exempt decimal effectiveTaxPercent = operatingCosts.TaxPercent; if (customerId.HasValue) { var selectedCustomer = await _unitOfWork.Customers.GetByIdAsync(customerId.Value); if (selectedCustomer?.IsTaxExempt == true) effectiveTaxPercent = 0; } var dto = new CreateQuoteDto { QuoteDate = DateTime.Today, ExpirationDate = DateTime.Today.AddDays(30), TaxPercent = effectiveTaxPercent, CustomerId = customerId }; if (guidedActivation == AppConstants.GuidedActivation.QuoteFirstPath) { var draft = GuidedActivationDefaults.BuildQuoteDraft(customerId); dto.CustomerId = draft.CustomerId; dto.Description = draft.Description; dto.Notes = draft.Notes; dto.QuoteItems = draft.QuoteItems; } ViewBag.GuidedActivation = guidedActivation; return View(dto); } catch (Exception ex) { _logger.LogError(ex, "Error loading quote creation page"); TempData["Error"] = "An error occurred while loading the page."; return RedirectToAction(nameof(Index)); } } /// /// Saves a new quote and all its line items, coats, and prep services in a single transaction. /// Supports both customer quotes and prospect quotes (IsForProspect=true). Prospect quotes /// store contact info directly on the Quote row until the prospect is converted to a customer. /// Pricing is calculated server-side via and snapshotted /// onto the Quote row — this means the totals are frozen at creation time and won't drift if /// operating costs are updated later. AI photo temp files are promoted to permanent paths here. /// Returns the new quote ID as JSON for the wizard to redirect to the Details page. /// [HttpPost] [ValidateAntiForgeryToken] public async Task Create(CreateQuoteDto dto, string? guidedActivation = null) { _logger.LogInformation("=== CREATE QUOTE POST ACTION CALLED ==="); _logger.LogInformation("IsForProspect: {IsForProspect}", dto.IsForProspect); _logger.LogInformation("CustomerId: {CustomerId}", dto.CustomerId); _logger.LogInformation("QuoteItems count: {Count}", dto.QuoteItems?.Count ?? 0); _logger.LogInformation("ModelState.IsValid: {IsValid}", ModelState.IsValid); // Debug: Log raw form data if QuoteItems is empty if ((dto.QuoteItems == null || dto.QuoteItems.Count == 0) && Request.HasFormContentType) { var formKeys = Request.Form.Keys.Where(k => k.StartsWith("QuoteItems[")).ToList(); _logger.LogWarning("QuoteItems collection is empty but found {Count} form keys starting with 'QuoteItems['", formKeys.Count); if (formKeys.Any()) { _logger.LogWarning("Sample form keys: {Keys}", string.Join(", ", formKeys.Take(10))); } } try { // Get current user var currentUser = await _userManager.GetUserAsync(User); // Validate that required operating costs are configured var operatingCosts = await _pricingService.GetOperatingCostsAsync(currentUser!.CompanyId); if (operatingCosts == null || operatingCosts.StandardLaborRate <= 0 || operatingCosts.GeneralMarkupPercentage <= 0) { var missingFields = new List(); if (operatingCosts == null || operatingCosts.StandardLaborRate <= 0) missingFields.Add("Standard Labor Rate"); if (operatingCosts == null || operatingCosts.GeneralMarkupPercentage <= 0) missingFields.Add("General Markup"); TempData["Error"] = $"Before creating a quote, please configure the following required operating costs: {string.Join(", ", missingFields)}. " + "Click the link below to set these values."; TempData["OperatingCostsLink"] = Url.Action("Index", "CompanySettings") + "#operating-costs"; return RedirectToAction(nameof(Index)); } // Subscription quote limit check if (!await _subscriptionService.CanAddQuoteAsync(currentUser.CompanyId)) { var (used, max) = await _subscriptionService.GetQuoteCountAsync(currentUser.CompanyId); TempData["Error"] = $"You have reached your plan limit of {max} quotes this month. " + "Your limit resets on the 1st of next month, or you can upgrade your plan."; return RedirectToAction(nameof(Index)); } // Resolve oven rate: use selected OvenCost if specified, otherwise fall back to default decimal? ovenRateOverride = null; if (dto.OvenCostId.HasValue) { var selectedOven = await _unitOfWork.OvenCosts.GetByIdAsync(dto.OvenCostId.Value); if (selectedOven != null && selectedOven.CompanyId == currentUser.CompanyId) ovenRateOverride = selectedOven.CostPerHour; } // Validate at least one quote item exists if (dto.QuoteItems == null || dto.QuoteItems.Count == 0) { _logger.LogWarning("Validation failed: No quote items provided"); ModelState.AddModelError("QuoteItems", "Please add at least one quote item."); } // Validate customer or prospect selection if (!dto.IsForProspect && !dto.CustomerId.HasValue) { _logger.LogWarning("Validation failed: No customer selected and not a prospect"); ModelState.AddModelError("CustomerId", "Please select a customer or choose to create a quote for a prospect/walk-in."); } if (dto.IsForProspect) { if (string.IsNullOrWhiteSpace(dto.ProspectContactName)) { ModelState.AddModelError("ProspectContactName", "Contact Name is required for prospects/walk-ins."); } if (string.IsNullOrWhiteSpace(dto.ProspectPhone) && string.IsNullOrWhiteSpace(dto.ProspectEmail)) { ModelState.AddModelError("ProspectPhone", "A phone number or email address is required."); } } // Validate quote items - description is required for calculated items only if (dto.QuoteItems != null) { for (int i = 0; i < dto.QuoteItems.Count; i++) { var item = dto.QuoteItems[i]; // For calculated items (non-catalog), description is required if (!item.CatalogItemId.HasValue && string.IsNullOrWhiteSpace(item.Description)) { ModelState.AddModelError($"QuoteItems[{i}].Description", "Description is required for calculated items."); } } } if (!ModelState.IsValid) { // Log validation errors for debugging _logger.LogWarning("=== ModelState Validation Failed ==="); foreach (var key in ModelState.Keys) { var state = ModelState[key]; if (state.Errors.Count > 0) { _logger.LogWarning("Key: {Key}, ValidationState: {ValidationState}, ErrorCount: {ErrorCount}", key, state.ValidationState, state.Errors.Count); foreach (var error in state.Errors) { if (!string.IsNullOrEmpty(error.ErrorMessage)) { _logger.LogWarning(" - ErrorMessage: {ErrorMessage}", error.ErrorMessage); } if (error.Exception != null) { _logger.LogWarning(" - Exception: {Exception}", error.Exception.Message); } } } } _logger.LogWarning("=== End ModelState Errors ==="); await PopulateDropDownsAsync(currentUser!.CompanyId, operatingCosts?.OvenOperatingCostPerHour ?? 0); await SetMeasurementViewBagAsync(); ViewBag.GuidedActivation = guidedActivation; return View(dto); } // Calculate pricing — pass oven batch settings so the saved total matches the live preview var pricingResult = await _pricingService.CalculateQuoteTotalsAsync( dto.QuoteItems, currentUser!.CompanyId, dto.CustomerId, dto.TaxPercent, dto.DiscountType, dto.DiscountValue, dto.IsRushJob, ovenRateOverride, dto.OvenBatches, dto.OvenCycleMinutes ); // Get status lookups (cached) var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var statuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId); var sentStatus = statuses.FirstOrDefault(s => s.StatusCode == "SENT"); var draftStatus = statuses.FirstOrDefault(s => s.StatusCode == "DRAFT"); // Create quote entity var quote = _mapper.Map(dto); quote.QuoteNumber = await GenerateQuoteNumberAsync(); quote.PreparedById = currentUser.Id; quote.CompanyId = currentUser.CompanyId; if (dto.SendEmailToCustomer) { quote.QuoteStatusId = sentStatus?.Id ?? draftStatus?.Id ?? 1; quote.SentDate = DateTime.UtcNow; } else { quote.QuoteStatusId = draftStatus?.Id ?? sentStatus?.Id ?? 1; quote.SentDate = null; } // Set calculated pricing — snapshot at save time; never recalculate on load quote.MaterialCosts = pricingResult.MaterialCosts; quote.LaborCosts = pricingResult.LaborCosts; quote.EquipmentCosts = pricingResult.EquipmentCosts; quote.ItemsSubtotal = pricingResult.ItemsSubtotal; quote.OvenBatchCost = pricingResult.OvenBatchCost; quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount; quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent; quote.OverheadAmount = pricingResult.OverheadCosts; quote.OverheadPercent = pricingResult.OverheadPercent; quote.ProfitMargin = pricingResult.ProfitMargin; quote.ProfitPercent = pricingResult.ProfitPercent; quote.SubTotal = pricingResult.SubtotalBeforeDiscount; quote.DiscountPercent = pricingResult.DiscountPercent; quote.DiscountAmount = pricingResult.DiscountAmount; quote.RushFee = pricingResult.RushFee; quote.TaxAmount = pricingResult.TaxAmount; quote.Total = pricingResult.Total; // Add quote await _unitOfWork.Quotes.AddAsync(quote); await _unitOfWork.CompleteAsync(); // Create quote items with calculated pricing var itemResults = new List(); foreach (var itemDto in dto.QuoteItems) { var item = _mapper.Map(itemDto); item.QuoteId = quote.Id; item.CompanyId = currentUser.CompanyId; // AI items: use stored price (AI estimate or user override) — skip the pricing engine if (itemDto.IsAiItem && itemDto.ManualUnitPrice.HasValue && itemDto.ManualUnitPrice.Value > 0) { item.UnitPrice = itemDto.ManualUnitPrice.Value; item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity; _logger.LogInformation("AI item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice); } // Sales/merchandise items: use the manually entered price directly — no coating calculation else if (itemDto.IsSalesItem && itemDto.ManualUnitPrice.HasValue) { item.UnitPrice = itemDto.ManualUnitPrice.Value; item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity; _logger.LogInformation("Sales item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice); } // Catalog items: if they have coats, calculate with coats; otherwise use default price else if (itemDto.CatalogItemId.HasValue) { // If catalog item has coats, calculate the full price with coat costs if (itemDto.Coats != null && itemDto.Coats.Any()) { _logger.LogInformation("Calculating catalog item with {CoatCount} coats", itemDto.Coats.Count); var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, currentUser.CompanyId, ovenRateOverride); item.UnitPrice = itemPricing.UnitPrice; item.TotalPrice = itemPricing.TotalPrice; item.ItemMaterialCost = itemPricing.MaterialCost; item.ItemLaborCost = itemPricing.LaborCost; item.ItemEquipmentCost = itemPricing.EquipmentCost; _logger.LogInformation("Set prices: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice); } else { // No coats - use catalog default price var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(itemDto.CatalogItemId.Value); if (catalogItem != null) { item.UnitPrice = catalogItem.DefaultPrice; item.TotalPrice = catalogItem.DefaultPrice * itemDto.Quantity; _logger.LogInformation("Catalog item no coats: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice); } } } else { // Calculated items use the pricing service _logger.LogInformation("Calculating custom item with {CoatCount} coats", itemDto.Coats?.Count ?? 0); var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, currentUser.CompanyId, ovenRateOverride); item.UnitPrice = itemPricing.UnitPrice; item.TotalPrice = itemPricing.TotalPrice; item.ItemMaterialCost = itemPricing.MaterialCost; item.ItemLaborCost = itemPricing.LaborCost; item.ItemEquipmentCost = itemPricing.EquipmentCost; _logger.LogInformation("Set prices: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice); } // Flag whether the user overrode the AI's estimates before accepting await UpdateAiPredictionOverrideAsync(itemDto, item.UnitPrice); // Map coats for this item with calculated costs if (itemDto.Coats != null && itemDto.Coats.Any()) { item.Coats = new List(); for (int coatIndex = 0; coatIndex < itemDto.Coats.Count; coatIndex++) { var coatDto = itemDto.Coats[coatIndex]; var coat = _mapper.Map(coatDto); coat.CompanyId = currentUser.CompanyId; // Calculate and store the coat costs var coatPricing = await _pricingService.CalculateCoatPriceAsync( coatDto, itemDto.SurfaceAreaSqFt, itemDto.Quantity, coatIndex, itemDto.EstimatedMinutes, currentUser.CompanyId); coat.CoatMaterialCost = coatPricing.CoatMaterialCost; coat.CoatLaborCost = coatPricing.CoatLaborCost; coat.CoatTotalCost = coatPricing.CoatTotalCost; item.Coats.Add(coat); } } // Map per-item prep services if (itemDto.PrepServices != null && itemDto.PrepServices.Any()) { item.PrepServices = new List(); foreach (var psDto in itemDto.PrepServices) { var prepService = _mapper.Map(psDto); prepService.CompanyId = currentUser.CompanyId; item.PrepServices.Add(prepService); } } itemResults.Add(item); } foreach (var item in itemResults) { await _unitOfWork.QuoteItems.AddAsync(item); } await _unitOfWork.CompleteAsync(); // Promote AI temp photos to permanent storage and create QuotePhoto records if (dto.AiPhotoTempIds?.Count > 0) { foreach (var rawTempId in dto.AiPhotoTempIds.Where(t => !string.IsNullOrWhiteSpace(t))) { if (!Guid.TryParse(rawTempId, out var tempGuid)) continue; var tempId = tempGuid.ToString("N"); var (promoted, photoPath, _) = await _photoService.PromoteTempPhotoAsync(tempId, quote.Id, currentUser.CompanyId); if (promoted) { var ext = Path.GetExtension(photoPath).ToLowerInvariant(); var ct = ext switch { ".jpg" or ".jpeg" => "image/jpeg", ".png" => "image/png", ".gif" => "image/gif", ".webp" => "image/webp", _ => "image/jpeg" }; var photoEntity = new QuotePhoto { QuoteId = quote.Id, TempId = tempId, FilePath = photoPath, FileName = Path.GetFileName(photoPath), FileSize = 0, ContentType = ct, IsAiAnalysisPhoto = true, UploadedById = currentUser.Id, CompanyId = currentUser.CompanyId }; await _unitOfWork.QuotePhotos.AddAsync(photoEntity); } } await _unitOfWork.CompleteAsync(); } // Promote general quote photo temp uploads _logger.LogInformation("CREATE Photo promotion: QuotePhotoTempIds count={Count}, raw values=[{Values}]", dto.QuotePhotoTempIds?.Count ?? 0, dto.QuotePhotoTempIds == null ? "" : string.Join(",", dto.QuotePhotoTempIds)); if (dto.QuotePhotoTempIds?.Count > 0) { foreach (var rawTempId in dto.QuotePhotoTempIds.Where(t => !string.IsNullOrWhiteSpace(t))) { if (!Guid.TryParse(rawTempId, out var tempGuid)) { _logger.LogWarning("CREATE Photo: Guid.TryParse failed for rawTempId={RawTempId}", rawTempId); continue; } var tempId = tempGuid.ToString("N"); _logger.LogInformation("CREATE Photo: promoting tempId={TempId} for quoteId={QuoteId}", tempId, quote.Id); if (!await _subscriptionService.CanAddQuotePhotoAsync(currentUser.CompanyId, quote.Id)) { _logger.LogWarning("CREATE Photo: CanAddQuotePhotoAsync returned false, breaking"); break; } var (promoted, photoPath, promoteError) = await _photoService.PromoteTempPhotoAsync(tempId, quote.Id, currentUser.CompanyId); _logger.LogInformation("CREATE Photo: PromoteTempPhotoAsync result: promoted={Promoted}, path={Path}, error={Error}", promoted, photoPath, promoteError); if (promoted) { var ext = Path.GetExtension(photoPath).ToLowerInvariant(); var ct = ext switch { ".jpg" or ".jpeg" => "image/jpeg", ".png" => "image/png", ".gif" => "image/gif", ".webp" => "image/webp", _ => "image/jpeg" }; var rawIdx = dto.QuotePhotoTempIds.IndexOf(rawTempId); var rawFileName = (dto.QuotePhotoFileNames?.Count > rawIdx && rawIdx >= 0) ? dto.QuotePhotoFileNames[rawIdx] : Path.GetFileName(photoPath); var photoEntity = new QuotePhoto { QuoteId = quote.Id, TempId = tempId, FilePath = photoPath, FileName = rawFileName, FileSize = 0, ContentType = ct, IsAiAnalysisPhoto = false, UploadedById = currentUser.Id, CompanyId = currentUser.CompanyId }; await _unitOfWork.QuotePhotos.AddAsync(photoEntity); _logger.LogInformation("CREATE Photo: QuotePhoto entity added to DB for quoteId={QuoteId}", quote.Id); } } await _unitOfWork.CompleteAsync(); } // Generate customer approval token (so the link is included in the quote email) var approvalTokenBytes = System.Security.Cryptography.RandomNumberGenerator.GetBytes(32); quote.ApprovalToken = Convert.ToBase64String(approvalTokenBytes) .Replace('+', '-').Replace('/', '_').TrimEnd('='); quote.ApprovalTokenExpiresAt = DateTime.UtcNow.AddDays( int.TryParse(await _platformSettings.GetAsync(PlatformSettingKeys.QuoteApprovalTokenDays), out var tokenDays) ? tokenDays : 30); quote.ApprovalTokenUsedAt = null; await _unitOfWork.Quotes.UpdateAsync(quote); await _unitOfWork.CompleteAsync(); // Optionally email the quote to the customer/prospect with the PDF attached if (dto.SendEmailToCustomer) { try { // Load Customer nav prop onto the already-token-stamped quote object // (avoids reloading from DB which could return a stale EF cache entry without the token) if (quote.Customer == null && quote.CustomerId.HasValue) quote.Customer = await _unitOfWork.Customers.GetByIdAsync(quote.CustomerId.Value); var quoteForNotify = quote; { byte[]? pdfBytes = null; string? pdfFilename = null; try { pdfBytes = await BuildQuotePdfAsync(quote.Id, currentUser); pdfFilename = $"Quote-{quote.QuoteNumber}.pdf"; } catch (Exception pdfEx) { _logger.LogWarning(pdfEx, "PDF generation failed for quote {Id}; sending email without attachment", quote.Id); } await _notificationService.NotifyQuoteSentAsync(quoteForNotify, pdfBytes, pdfFilename); } } catch (Exception ex) { _logger.LogWarning(ex, "Notification failed for quote {Id}", quote.Id); } var quoteCreateNotifLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(quote.Id); this.SetNotificationResultToast(quoteCreateNotifLog); } await StampQuoteCreatedAsync(currentUser.CompanyId); this.ToastSuccess($"Quote {quote.QuoteNumber} created successfully!"); if (guidedActivation == AppConstants.GuidedActivation.QuoteFirstPath) { return RedirectToAction(nameof(Details), new { id = quote.Id, guidedActivation = AppConstants.GuidedActivation.QuoteCreatedStep }); } return RedirectToAction(nameof(Details), new { id = quote.Id }); } catch (Exception ex) { _logger.LogError(ex, "Error creating quote"); this.ToastError("An error occurred while creating the quote. Please try again."); var catchUser = await _userManager.GetUserAsync(User); var catchCosts = await _pricingService.GetOperatingCostsAsync(catchUser!.CompanyId); await PopulateDropDownsAsync(catchUser.CompanyId, catchCosts?.OvenOperatingCostPerHour ?? 0); await SetMeasurementViewBagAsync(); ViewBag.GuidedActivation = guidedActivation; return View(dto); } } /// /// Loads the quote edit wizard pre-populated with existing data. /// Only Draft and Sent quotes can be edited — Approved/Rejected/Converted quotes are read-only /// to preserve the audit trail. The view is the same wizard as Create but with all fields bound. /// public async Task Edit(int? id) { if (id == null) { return NotFound(); } try { // Load quote with customer information var quote = await _unitOfWork.Quotes.GetByIdAsync(id.Value, false, q => q.Customer, q => q.QuoteStatus); if (quote == null) { return NotFound(); } // Get quote items with their coats, prep services and catalog item var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(id.Value); _logger.LogInformation("=== LOADING QUOTE {QuoteId} FOR EDIT ===", id.Value); foreach (var item in quoteItems) { _logger.LogInformation("Loaded item from DB: {Desc}, Coats={CoatCount}", item.Description, item.Coats?.Count ?? 0); if (item.Coats != null) { foreach (var coat in item.Coats) { _logger.LogInformation(" - Coat: {CoatName}", coat.CoatName); } } } // Map to DTO (PrepServices are auto-mapped per-item via QuoteProfile) var dto = _mapper.Map(quote); dto.QuoteItems = _mapper.Map>(quoteItems); _logger.LogInformation("After mapping to DTO: {ItemCount} items", dto.QuoteItems.Count); for (int i = 0; i < dto.QuoteItems.Count; i++) { var dtoItem = dto.QuoteItems[i]; _logger.LogInformation("DTO item {Index}: {Desc}, Coats={CoatCount}", i, dtoItem.Description, dtoItem.Coats?.Count ?? 0); if (dtoItem.Coats != null) { foreach (var coat in dtoItem.Coats) { _logger.LogInformation(" - DTO Coat: {CoatName}", coat.CoatName); } } } // Pass customer information to view for display if (quote.CustomerId.HasValue && quote.Customer != null) { ViewBag.CustomerName = quote.Customer.CompanyName ?? ""; ViewBag.CustomerContactName = $"{quote.Customer.ContactFirstName} {quote.Customer.ContactLastName}".Trim(); ViewBag.CustomerEmail = quote.Customer.Email ?? ""; ViewBag.CustomerPhone = quote.Customer.Phone ?? ""; } var editCurrentUser = await _userManager.GetUserAsync(User); var editCosts = await _pricingService.GetOperatingCostsAsync(editCurrentUser!.CompanyId); await PopulateDropDownsAsync(editCurrentUser.CompanyId, editCosts?.OvenOperatingCostPerHour ?? 0); await SetMeasurementViewBagAsync(); // Quote photos for edit view var editPhotos = await _unitOfWork.QuotePhotos.FindAsync(p => p.QuoteId == id.Value); ViewBag.QuotePhotos = editPhotos.OrderBy(p => p.CreatedAt).ToList(); ViewBag.CanUploadQuotePhoto = await _subscriptionService.CanAddQuotePhotoAsync(editCurrentUser.CompanyId, id.Value); var (editPhotoUsed, editPhotoMax) = await _subscriptionService.GetQuotePhotoCountAsync(editCurrentUser.CompanyId, id.Value); ViewBag.QuotePhotoUsed = editPhotoUsed; ViewBag.QuotePhotoMax = editPhotoMax; return View(dto); } catch (Exception ex) { _logger.LogError(ex, "Error loading quote for edit: {QuoteId}", id); TempData["Error"] = "An error occurred while loading the quote."; return RedirectToAction(nameof(Index)); } } // POST: Quotes/Edit/5 /// /// Saves edits to an existing quote: replaces all line items, coats, and prep services, /// then recalculates and re-snapshots the pricing totals. Each save appends a /// row capturing what changed and who changed it, so we /// have a full audit trail of revisions. The quote number is preserved; a new PDF is /// generated on next download rather than being cached. /// [HttpPost] [ValidateAntiForgeryToken] public async Task Edit(int id, UpdateQuoteDto dto) { _logger.LogInformation("=== EDIT QUOTE POST ACTION CALLED ==="); _logger.LogInformation("Quote ID: {Id}, DTO ID: {DtoId}", id, dto.Id); _logger.LogInformation("CustomerId: {CustomerId}, IsForProspect: {IsForProspect}", dto.CustomerId, dto.IsForProspect); _logger.LogInformation("QuoteItems count: {Count}", dto.QuoteItems?.Count ?? 0); // Log each item's sandblasting/masking values for (int i = 0; i < (dto.QuoteItems?.Count ?? 0); i++) { var item = dto.QuoteItems![i]; _logger.LogInformation("Item {Index}: Description={Desc}, Sandblasting={Sand}, Masking={Mask}", i, item.Description, item.RequiresSandblasting, item.RequiresMasking); } _logger.LogInformation("Initial ModelState.IsValid: {IsValid}", ModelState.IsValid); if (id != dto.Id) { _logger.LogWarning("ID mismatch: route id={RouteId}, dto.Id={DtoId}", id, dto.Id); return NotFound(); } try { // Load existing quote first to preserve customer assignment var quote = await _unitOfWork.Quotes.GetByIdAsync(id); if (quote == null) { _logger.LogWarning("Quote {QuoteId} not found", id); return NotFound(); } // IsForProspect derives from whether a customer was selected in the form dto.IsForProspect = !dto.CustomerId.HasValue; // Validate at least one quote item exists if (dto.QuoteItems == null || dto.QuoteItems.Count == 0) { _logger.LogWarning("Validation failed: No quote items provided"); ModelState.AddModelError("QuoteItems", "Please add at least one quote item."); } // Validate customer or prospect selection if (!dto.IsForProspect && !dto.CustomerId.HasValue) { ModelState.AddModelError("CustomerId", "Please select a customer or choose prospect/walk-in."); } if (dto.IsForProspect) { if (string.IsNullOrWhiteSpace(dto.ProspectContactName)) { ModelState.AddModelError("ProspectContactName", "Contact Name is required for prospects/walk-ins."); } if (string.IsNullOrWhiteSpace(dto.ProspectPhone) && string.IsNullOrWhiteSpace(dto.ProspectEmail)) { ModelState.AddModelError("ProspectPhone", "A phone number or email address is required."); } } // Get current user early (needed for oven costs dropdown) var currentUser = await _userManager.GetUserAsync(User); var editOperatingCosts = await _pricingService.GetOperatingCostsAsync(currentUser!.CompanyId); if (!ModelState.IsValid) { _logger.LogWarning("ModelState is invalid. Errors: {Errors}", string.Join("; ", ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage))); await PopulateDropDownsAsync(currentUser.CompanyId, editOperatingCosts?.OvenOperatingCostPerHour ?? 0); await SetMeasurementViewBagAsync(); return View(dto); } // Resolve oven rate: use selected OvenCost if specified, otherwise fall back to default decimal? ovenRateOverride = null; if (dto.OvenCostId.HasValue) { var selectedOven = await _unitOfWork.OvenCosts.GetByIdAsync(dto.OvenCostId.Value); if (selectedOven != null && selectedOven.CompanyId == currentUser.CompanyId) ovenRateOverride = selectedOven.CostPerHour; } // Calculate pricing — pass oven batch settings so the saved total matches the live preview var pricingResult = await _pricingService.CalculateQuoteTotalsAsync( dto.QuoteItems, currentUser!.CompanyId, dto.CustomerId, dto.TaxPercent, dto.DiscountType, dto.DiscountValue, dto.IsRushJob, ovenRateOverride, dto.OvenBatches, dto.OvenCycleMinutes ); // Capture old values for change tracking var oldValues = new { QuoteStatusId = quote.QuoteStatusId, QuoteDate = quote.QuoteDate, ExpirationDate = quote.ExpirationDate, Terms = quote.Terms, Notes = quote.Notes, TaxPercent = quote.TaxPercent, DiscountType = quote.DiscountType, DiscountValue = quote.DiscountValue, DiscountReason = quote.DiscountReason, ProspectCompanyName = quote.ProspectCompanyName, ProspectContactName = quote.ProspectContactName, ProspectEmail = quote.ProspectEmail, ProspectPhone = quote.ProspectPhone, ProspectAddress = quote.ProspectAddress }; // Update quote entity _mapper.Map(dto, quote); // Set calculated pricing — snapshot at save time; never recalculate on load quote.MaterialCosts = pricingResult.MaterialCosts; quote.LaborCosts = pricingResult.LaborCosts; quote.EquipmentCosts = pricingResult.EquipmentCosts; quote.ItemsSubtotal = pricingResult.ItemsSubtotal; quote.OvenBatchCost = pricingResult.OvenBatchCost; quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount; quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent; quote.OverheadAmount = pricingResult.OverheadCosts; quote.OverheadPercent = pricingResult.OverheadPercent; quote.ProfitMargin = pricingResult.ProfitMargin; quote.ProfitPercent = pricingResult.ProfitPercent; quote.SubTotal = pricingResult.SubtotalBeforeDiscount; quote.DiscountPercent = pricingResult.DiscountPercent; quote.DiscountAmount = pricingResult.DiscountAmount; quote.RushFee = pricingResult.RushFee; quote.TaxAmount = pricingResult.TaxAmount; quote.Total = pricingResult.Total; // Track changes var changeHistories = new List(); _logger.LogInformation("=== CHANGE TRACKING DEBUG ==="); _logger.LogInformation("Old Status: {OldStatus}, New Status: {NewStatus}", oldValues.QuoteStatusId, quote.QuoteStatusId); _logger.LogInformation("Old Date: {OldDate}, New Date: {NewDate}", oldValues.QuoteDate, quote.QuoteDate); _logger.LogInformation("Old Expiration: {OldExp}, New Expiration: {NewExp}", oldValues.ExpirationDate, quote.ExpirationDate); _logger.LogInformation("Old Terms: {OldTerms}, New Terms: {NewTerms}", oldValues.Terms, quote.Terms); _logger.LogInformation("Old Notes: {OldNotes}, New Notes: {NewNotes}", oldValues.Notes, quote.Notes); _logger.LogInformation("Old Tax: {OldTax}, New Tax: {NewTax}", oldValues.TaxPercent, quote.TaxPercent); if (oldValues.QuoteStatusId != quote.QuoteStatusId) { var oldStatus = await _unitOfWork.QuoteStatusLookups.GetByIdAsync(oldValues.QuoteStatusId); var newStatus = await _unitOfWork.QuoteStatusLookups.GetByIdAsync(quote.QuoteStatusId); changeHistories.Add(new QuoteChangeHistory { QuoteId = quote.Id, ChangedByUserId = currentUser.Id, ChangedAt = DateTime.UtcNow, FieldName = "Status", OldValue = oldStatus?.DisplayName ?? oldValues.QuoteStatusId.ToString(), NewValue = newStatus?.DisplayName ?? quote.QuoteStatusId.ToString(), ChangeDescription = $"Status changed from {oldStatus?.DisplayName} to {newStatus?.DisplayName}", CompanyId = currentUser.CompanyId }); } if (oldValues.QuoteDate != quote.QuoteDate) { changeHistories.Add(new QuoteChangeHistory { QuoteId = quote.Id, ChangedByUserId = currentUser.Id, ChangedAt = DateTime.UtcNow, FieldName = "Quote Date", OldValue = oldValues.QuoteDate.ToString("MM/dd/yyyy"), NewValue = quote.QuoteDate.ToString("MM/dd/yyyy"), ChangeDescription = $"Quote date changed from {oldValues.QuoteDate:MM/dd/yyyy} to {quote.QuoteDate:MM/dd/yyyy}", CompanyId = currentUser.CompanyId }); } if (oldValues.ExpirationDate != quote.ExpirationDate) { changeHistories.Add(new QuoteChangeHistory { QuoteId = quote.Id, ChangedByUserId = currentUser.Id, ChangedAt = DateTime.UtcNow, FieldName = "Expiration Date", OldValue = oldValues.ExpirationDate?.ToString("MM/dd/yyyy") ?? "None", NewValue = quote.ExpirationDate?.ToString("MM/dd/yyyy") ?? "None", ChangeDescription = $"Expiration date changed from {oldValues.ExpirationDate?.ToString("MM/dd/yyyy") ?? "None"} to {quote.ExpirationDate?.ToString("MM/dd/yyyy") ?? "None"}", CompanyId = currentUser.CompanyId }); } if (oldValues.Terms != quote.Terms) { changeHistories.Add(new QuoteChangeHistory { QuoteId = quote.Id, ChangedByUserId = currentUser.Id, ChangedAt = DateTime.UtcNow, FieldName = "Terms", OldValue = oldValues.Terms ?? "None", NewValue = quote.Terms ?? "None", ChangeDescription = $"Terms changed from '{oldValues.Terms ?? "None"}' to '{quote.Terms ?? "None"}'", CompanyId = currentUser.CompanyId }); } if (oldValues.Notes != quote.Notes) { changeHistories.Add(new QuoteChangeHistory { QuoteId = quote.Id, ChangedByUserId = currentUser.Id, ChangedAt = DateTime.UtcNow, FieldName = "Notes", OldValue = string.IsNullOrEmpty(oldValues.Notes) ? "None" : (oldValues.Notes.Length > 50 ? oldValues.Notes.Substring(0, 50) + "..." : oldValues.Notes), NewValue = string.IsNullOrEmpty(quote.Notes) ? "None" : (quote.Notes.Length > 50 ? quote.Notes.Substring(0, 50) + "..." : quote.Notes), ChangeDescription = "Notes updated", CompanyId = currentUser.CompanyId }); } if (oldValues.TaxPercent != quote.TaxPercent) { changeHistories.Add(new QuoteChangeHistory { QuoteId = quote.Id, ChangedByUserId = currentUser.Id, ChangedAt = DateTime.UtcNow, FieldName = "Tax Percent", OldValue = $"{oldValues.TaxPercent:N2}%", NewValue = $"{quote.TaxPercent:N2}%", ChangeDescription = $"Tax percent changed from {oldValues.TaxPercent:N2}% to {quote.TaxPercent:N2}%", CompanyId = currentUser.CompanyId }); } // Track discount changes if (oldValues.DiscountType != quote.DiscountType || oldValues.DiscountValue != quote.DiscountValue || oldValues.DiscountReason != quote.DiscountReason) { var oldDiscountDescription = FormatDiscountForHistory( oldValues.DiscountType, oldValues.DiscountValue, oldValues.DiscountReason); var newDiscountDescription = FormatDiscountForHistory( quote.DiscountType, quote.DiscountValue, quote.DiscountReason); changeHistories.Add(new QuoteChangeHistory { QuoteId = quote.Id, ChangedByUserId = currentUser.Id, ChangedAt = DateTime.UtcNow, FieldName = "Discount", OldValue = oldDiscountDescription, NewValue = newDiscountDescription, ChangeDescription = $"Discount changed from {oldDiscountDescription} to {newDiscountDescription}", CompanyId = currentUser.CompanyId }); } // Track prospect field changes (if applicable) if (!quote.CustomerId.HasValue) { if (oldValues.ProspectCompanyName != quote.ProspectCompanyName) { changeHistories.Add(new QuoteChangeHistory { QuoteId = quote.Id, ChangedByUserId = currentUser.Id, ChangedAt = DateTime.UtcNow, FieldName = "Prospect Company Name", OldValue = oldValues.ProspectCompanyName ?? "None", NewValue = quote.ProspectCompanyName ?? "None", ChangeDescription = $"Prospect company changed from '{oldValues.ProspectCompanyName ?? "None"}' to '{quote.ProspectCompanyName ?? "None"}'", CompanyId = currentUser.CompanyId }); } if (oldValues.ProspectContactName != quote.ProspectContactName) { changeHistories.Add(new QuoteChangeHistory { QuoteId = quote.Id, ChangedByUserId = currentUser.Id, ChangedAt = DateTime.UtcNow, FieldName = "Prospect Contact Name", OldValue = oldValues.ProspectContactName ?? "None", NewValue = quote.ProspectContactName ?? "None", ChangeDescription = $"Prospect contact changed from '{oldValues.ProspectContactName ?? "None"}' to '{quote.ProspectContactName ?? "None"}'", CompanyId = currentUser.CompanyId }); } if (oldValues.ProspectEmail != quote.ProspectEmail) { changeHistories.Add(new QuoteChangeHistory { QuoteId = quote.Id, ChangedByUserId = currentUser.Id, ChangedAt = DateTime.UtcNow, FieldName = "Prospect Email", OldValue = oldValues.ProspectEmail ?? "None", NewValue = quote.ProspectEmail ?? "None", ChangeDescription = $"Prospect email changed from '{oldValues.ProspectEmail ?? "None"}' to '{quote.ProspectEmail ?? "None"}'", CompanyId = currentUser.CompanyId }); } if (oldValues.ProspectPhone != quote.ProspectPhone) { changeHistories.Add(new QuoteChangeHistory { QuoteId = quote.Id, ChangedByUserId = currentUser.Id, ChangedAt = DateTime.UtcNow, FieldName = "Prospect Phone", OldValue = oldValues.ProspectPhone ?? "None", NewValue = quote.ProspectPhone ?? "None", ChangeDescription = $"Prospect phone changed from '{oldValues.ProspectPhone ?? "None"}' to '{quote.ProspectPhone ?? "None"}'", CompanyId = currentUser.CompanyId }); } if (oldValues.ProspectAddress != quote.ProspectAddress) { changeHistories.Add(new QuoteChangeHistory { QuoteId = quote.Id, ChangedByUserId = currentUser.Id, ChangedAt = DateTime.UtcNow, FieldName = "Prospect Address", OldValue = oldValues.ProspectAddress ?? "None", NewValue = quote.ProspectAddress ?? "None", ChangeDescription = $"Prospect address changed", CompanyId = currentUser.CompanyId }); } } // Save change history records _logger.LogInformation("Change histories detected: {Count}", changeHistories.Count); foreach (var history in changeHistories) { _logger.LogInformation("Adding change history: {Field} - {OldValue} -> {NewValue}", history.FieldName, history.OldValue, history.NewValue); await _unitOfWork.QuoteChangeHistories.AddAsync(history); } await _unitOfWork.Quotes.UpdateAsync(quote); // Delete existing quote items (but capture them first for change tracking) var existingItems = await _unitOfWork.QuoteItems.FindAsync(qi => qi.QuoteId == id); var oldItemsForComparison = existingItems.Select(i => new { i.Description, i.Quantity, i.UnitPrice, i.TotalPrice, i.RequiresSandblasting, i.RequiresMasking, i.SurfaceAreaSqFt, i.Notes }).ToList(); foreach (var item in existingItems) { await _unitOfWork.QuoteItems.DeleteAsync(item); } // Create new quote items with calculated pricing var newItemsForComparison = new List<(string Description, decimal Quantity, decimal UnitPrice, decimal TotalPrice, bool Sandblasting, bool Masking, decimal? SurfaceArea, string? Notes)>(); foreach (var itemDto in dto.QuoteItems) { var item = _mapper.Map(itemDto); item.QuoteId = quote.Id; item.CompanyId = currentUser.CompanyId; _logger.LogInformation("Creating item: {Desc}, Sandblasting={Sand}, Masking={Mask} (from DTO: Sand={DtoSand}, Mask={DtoMask})", item.Description, item.RequiresSandblasting, item.RequiresMasking, itemDto.RequiresSandblasting, itemDto.RequiresMasking); // AI items: use stored price (AI estimate or user override) — skip the pricing engine if (itemDto.IsAiItem && itemDto.ManualUnitPrice.HasValue && itemDto.ManualUnitPrice.Value > 0) { item.UnitPrice = itemDto.ManualUnitPrice.Value; item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity; _logger.LogInformation("AI item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice); } // Sales/merchandise items: use the manually entered price directly — no coating calculation else if (itemDto.IsSalesItem && itemDto.ManualUnitPrice.HasValue) { item.UnitPrice = itemDto.ManualUnitPrice.Value; item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity; _logger.LogInformation("Sales item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice); } // Catalog items: if they have coats, calculate with coats; otherwise use default price else if (itemDto.CatalogItemId.HasValue) { // If catalog item has coats, calculate the full price with coat costs if (itemDto.Coats != null && itemDto.Coats.Any()) { _logger.LogInformation("Calculating catalog item with {CoatCount} coats", itemDto.Coats.Count); var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, currentUser.CompanyId, ovenRateOverride); item.UnitPrice = itemPricing.UnitPrice; item.TotalPrice = itemPricing.TotalPrice; item.ItemMaterialCost = itemPricing.MaterialCost; item.ItemLaborCost = itemPricing.LaborCost; item.ItemEquipmentCost = itemPricing.EquipmentCost; _logger.LogInformation("Set prices: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice); } else { // No coats - use catalog default price var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(itemDto.CatalogItemId.Value); if (catalogItem != null) { item.UnitPrice = catalogItem.DefaultPrice; item.TotalPrice = catalogItem.DefaultPrice * itemDto.Quantity; _logger.LogInformation("Catalog item no coats: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice); } } } else { // Calculated items use the pricing service _logger.LogInformation("Calculating custom item with {CoatCount} coats", itemDto.Coats?.Count ?? 0); var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, currentUser.CompanyId, ovenRateOverride); item.UnitPrice = itemPricing.UnitPrice; item.TotalPrice = itemPricing.TotalPrice; item.ItemMaterialCost = itemPricing.MaterialCost; item.ItemLaborCost = itemPricing.LaborCost; item.ItemEquipmentCost = itemPricing.EquipmentCost; _logger.LogInformation("Set prices: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice); } // Flag whether the user overrode the AI's estimates before accepting await UpdateAiPredictionOverrideAsync(itemDto, item.UnitPrice); await _unitOfWork.QuoteItems.AddAsync(item); // Map coats for this item with calculated costs if (itemDto.Coats != null && itemDto.Coats.Any()) { item.Coats = new List(); for (int coatIndex = 0; coatIndex < itemDto.Coats.Count; coatIndex++) { var coatDto = itemDto.Coats[coatIndex]; var coat = _mapper.Map(coatDto); coat.CompanyId = currentUser.CompanyId; // Calculate and store the coat costs var coatPricing = await _pricingService.CalculateCoatPriceAsync( coatDto, itemDto.SurfaceAreaSqFt, itemDto.Quantity, coatIndex, itemDto.EstimatedMinutes, currentUser.CompanyId); coat.CoatMaterialCost = coatPricing.CoatMaterialCost; coat.CoatLaborCost = coatPricing.CoatLaborCost; coat.CoatTotalCost = coatPricing.CoatTotalCost; item.Coats.Add(coat); } _logger.LogInformation("Added {CoatCount} coats to item {Description}", item.Coats.Count, item.Description); } // Map per-item prep services if (itemDto.PrepServices != null && itemDto.PrepServices.Any()) { item.PrepServices = new List(); foreach (var psDto in itemDto.PrepServices) { var prepService = _mapper.Map(psDto); prepService.CompanyId = currentUser.CompanyId; item.PrepServices.Add(prepService); } } // Track new item for comparison newItemsForComparison.Add(( item.Description ?? "", item.Quantity, item.UnitPrice, item.TotalPrice, item.RequiresSandblasting, item.RequiresMasking, item.SurfaceAreaSqFt, item.Notes )); } // Track quote item changes _logger.LogInformation("Comparing quote items: Old={OldCount}, New={NewCount}", oldItemsForComparison.Count, newItemsForComparison.Count); // Detect added items if (newItemsForComparison.Count > oldItemsForComparison.Count) { for (int i = oldItemsForComparison.Count; i < newItemsForComparison.Count; i++) { var newItem = newItemsForComparison[i]; var history = new QuoteChangeHistory { QuoteId = quote.Id, ChangedByUserId = currentUser.Id, ChangedAt = DateTime.UtcNow, FieldName = "Quote Items", OldValue = null, NewValue = $"{newItem.Description} (Qty: {newItem.Quantity}, Price: {newItem.TotalPrice:C})", ChangeDescription = $"Item added: {newItem.Description}", CompanyId = currentUser.CompanyId }; await _unitOfWork.QuoteChangeHistories.AddAsync(history); _logger.LogInformation("Item added: {Desc}", newItem.Description); } } // Detect removed items if (oldItemsForComparison.Count > newItemsForComparison.Count) { for (int i = newItemsForComparison.Count; i < oldItemsForComparison.Count; i++) { var oldItem = oldItemsForComparison[i]; var history = new QuoteChangeHistory { QuoteId = quote.Id, ChangedByUserId = currentUser.Id, ChangedAt = DateTime.UtcNow, FieldName = "Quote Items", OldValue = $"{oldItem.Description} (Qty: {oldItem.Quantity}, Price: {oldItem.TotalPrice:C})", NewValue = null, ChangeDescription = $"Item removed: {oldItem.Description}", CompanyId = currentUser.CompanyId }; await _unitOfWork.QuoteChangeHistories.AddAsync(history); _logger.LogInformation("Item removed: {Desc}", oldItem.Description); } } // Detect modified items (compare items at same index) var itemsToCompare = Math.Min(oldItemsForComparison.Count, newItemsForComparison.Count); for (int i = 0; i < itemsToCompare; i++) { var oldItem = oldItemsForComparison[i]; var newItem = newItemsForComparison[i]; var itemChanges = new List(); if (oldItem.Description != newItem.Description) itemChanges.Add($"Description: '{oldItem.Description}' → '{newItem.Description}'"); if (oldItem.Quantity != newItem.Quantity) itemChanges.Add($"Quantity: {oldItem.Quantity} → {newItem.Quantity}"); // Use rounding to 2 decimal places for price comparisons to avoid false positives from calculation precision if (Math.Round(oldItem.UnitPrice, 2) != Math.Round(newItem.UnitPrice, 2)) itemChanges.Add($"Unit Price: {oldItem.UnitPrice:C} → {newItem.UnitPrice:C}"); if (Math.Round(oldItem.TotalPrice, 2) != Math.Round(newItem.TotalPrice, 2)) itemChanges.Add($"Total: {oldItem.TotalPrice:C} → {newItem.TotalPrice:C}"); if (oldItem.RequiresSandblasting != newItem.Sandblasting) itemChanges.Add($"Sandblasting: {oldItem.RequiresSandblasting} → {newItem.Sandblasting}"); if (oldItem.RequiresMasking != newItem.Masking) itemChanges.Add($"Masking: {oldItem.RequiresMasking} → {newItem.Masking}"); // Use rounding for surface area comparison var oldArea = Math.Round(oldItem.SurfaceAreaSqFt, 2); var newArea = newItem.SurfaceArea.HasValue ? Math.Round(newItem.SurfaceArea.Value, 2) : 0m; if (oldArea != newArea) itemChanges.Add($"Surface Area: {oldItem.SurfaceAreaSqFt} → {newItem.SurfaceArea}"); if (oldItem.Notes != newItem.Notes) itemChanges.Add($"Notes changed"); if (itemChanges.Any()) { var history = new QuoteChangeHistory { QuoteId = quote.Id, ChangedByUserId = currentUser.Id, ChangedAt = DateTime.UtcNow, FieldName = "Quote Items", OldValue = $"Item #{i + 1}: {oldItem.Description}", NewValue = string.Join("; ", itemChanges), ChangeDescription = $"Item #{i + 1} modified: {string.Join(", ", itemChanges)}", CompanyId = currentUser.CompanyId }; await _unitOfWork.QuoteChangeHistories.AddAsync(history); _logger.LogInformation("Item #{Index} modified: {Changes}", i + 1, string.Join(", ", itemChanges)); } } await _unitOfWork.CompleteAsync(); // Promote any new AI temp photos and create QuotePhoto records if (dto.AiPhotoTempIds?.Count > 0) { foreach (var rawTempId in dto.AiPhotoTempIds.Where(t => !string.IsNullOrWhiteSpace(t))) { if (!Guid.TryParse(rawTempId, out var tempGuid)) continue; var tempId = tempGuid.ToString("N"); var (promoted, photoPath, _) = await _photoService.PromoteTempPhotoAsync(tempId, quote.Id, currentUser.CompanyId); if (promoted) { var ext = Path.GetExtension(photoPath).ToLowerInvariant(); var ct = ext switch { ".jpg" or ".jpeg" => "image/jpeg", ".png" => "image/png", ".gif" => "image/gif", ".webp" => "image/webp", _ => "image/jpeg" }; var photoEntity = new QuotePhoto { QuoteId = quote.Id, TempId = tempId, FilePath = photoPath, FileName = Path.GetFileName(photoPath), FileSize = 0, ContentType = ct, IsAiAnalysisPhoto = true, UploadedById = currentUser.Id, CompanyId = currentUser.CompanyId }; await _unitOfWork.QuotePhotos.AddAsync(photoEntity); } } await _unitOfWork.CompleteAsync(); } // Promote general quote photo temp uploads (Edit page) if (dto.QuotePhotoTempIds?.Count > 0) { foreach (var rawTempId in dto.QuotePhotoTempIds.Where(t => !string.IsNullOrWhiteSpace(t))) { if (!Guid.TryParse(rawTempId, out var tempGuid)) continue; var tempId = tempGuid.ToString("N"); if (!await _subscriptionService.CanAddQuotePhotoAsync(currentUser.CompanyId, quote.Id)) break; var (promoted, photoPath, _) = await _photoService.PromoteTempPhotoAsync(tempId, quote.Id, currentUser.CompanyId); if (promoted) { var ext = Path.GetExtension(photoPath).ToLowerInvariant(); var ct = ext switch { ".jpg" or ".jpeg" => "image/jpeg", ".png" => "image/png", ".gif" => "image/gif", ".webp" => "image/webp", _ => "image/jpeg" }; var rawIdx = dto.QuotePhotoTempIds.IndexOf(rawTempId); var rawFileName = (dto.QuotePhotoFileNames?.Count > rawIdx && rawIdx >= 0) ? dto.QuotePhotoFileNames[rawIdx] : Path.GetFileName(photoPath); var photoEntity = new QuotePhoto { QuoteId = quote.Id, TempId = tempId, FilePath = photoPath, FileName = rawFileName, FileSize = 0, ContentType = ct, IsAiAnalysisPhoto = false, UploadedById = currentUser.Id, CompanyId = currentUser.CompanyId }; await _unitOfWork.QuotePhotos.AddAsync(photoEntity); } } await _unitOfWork.CompleteAsync(); } _logger.LogInformation("Quote {QuoteNumber} updated successfully", quote.QuoteNumber); this.ToastSuccess($"Quote {quote.QuoteNumber} updated successfully!"); return RedirectToAction(nameof(Details), new { id = quote.Id }); } catch (Exception ex) { _logger.LogError(ex, "Error updating quote {QuoteId}", id); TempData["Error"] = "An error occurred while updating the quote."; // Re-populate customer info for display on error if (dto.CustomerId.HasValue) { var customerQuote = await _unitOfWork.Quotes.GetByIdAsync(id, false, q => q.Customer); if (customerQuote?.Customer != null) { ViewBag.CustomerName = customerQuote.Customer.CompanyName ?? ""; ViewBag.CustomerContactName = $"{customerQuote.Customer.ContactFirstName} {customerQuote.Customer.ContactLastName}".Trim(); ViewBag.CustomerEmail = customerQuote.Customer.Email ?? ""; ViewBag.CustomerPhone = customerQuote.Customer.Phone ?? ""; } } var errUser = await _userManager.GetUserAsync(User); var errCosts = await _pricingService.GetOperatingCostsAsync(errUser!.CompanyId); await PopulateDropDownsAsync(errUser.CompanyId, errCosts?.OvenOperatingCostPerHour ?? 0); await SetMeasurementViewBagAsync(); return View(dto); } } /// /// Shows the prospect-to-customer conversion form, pre-populated with the prospect /// contact details stored on the quote. Only Approved prospect quotes may be converted /// (guard is enforced in the POST action). /// public async Task ConvertToCustomer(int? id) { if (id == null) { return NotFound(); } try { var quote = await _unitOfWork.Quotes.GetByIdAsync(id.Value, false, q => q.QuoteStatus); if (quote == null) { return NotFound(); } // Check if this is a prospect quote if (quote.CustomerId.HasValue) { TempData["Error"] = "This quote is already associated with a customer."; return RedirectToAction(nameof(Details), new { id }); } // Check if quote is approved if (quote.QuoteStatus.StatusCode != "APPROVED") { TempData["Error"] = "Only approved quotes can be converted to customers."; return RedirectToAction(nameof(Details), new { id }); } // Map to conversion DTO var dto = _mapper.Map(quote); // Populate pricing tiers dropdown await PopulatePricingTiersDropDownAsync(); return View(dto); } catch (Exception ex) { _logger.LogError(ex, "Error loading prospect conversion for quote {QuoteId}", id); TempData["Error"] = "An error occurred while loading the conversion page."; return RedirectToAction(nameof(Details), new { id }); } } /// /// Converts an approved prospect quote into a real customer record. /// Creates a new from the prospect fields on the quote, then links /// the quote to that customer and clears the prospect fields. The quote status is set to /// "Converted" so it appears correctly in the pipeline. This is a one-way operation — /// there is no undo; once a customer record exists the prospect fields are gone. /// [HttpPost] [ValidateAntiForgeryToken] public async Task ConvertToCustomer(ConvertQuoteToCustomerDto dto) { try { // Conditional validation: company name required for commercial customers if (dto.IsCommercial && string.IsNullOrWhiteSpace(dto.CompanyName)) { ModelState.AddModelError(nameof(dto.CompanyName), "Company Name is required for commercial customers."); } // At least one contact method required if (string.IsNullOrWhiteSpace(dto.Email) && string.IsNullOrWhiteSpace(dto.Phone)) { ModelState.AddModelError(nameof(dto.Email), "An email address or phone number is required."); } if (!ModelState.IsValid) { await PopulatePricingTiersDropDownAsync(); return View(dto); } var quote = await _unitOfWork.Quotes.GetByIdAsync(dto.QuoteId); if (quote == null) { return NotFound(); } // Double-check it's a prospect quote if (quote.CustomerId.HasValue) { TempData["Error"] = "This quote is already associated with a customer."; return RedirectToAction(nameof(Details), new { id = dto.QuoteId }); } var currentUser = await _userManager.GetUserAsync(User); // Create customer from prospect data var customer = _mapper.Map(dto); customer.CompanyId = currentUser!.CompanyId; await _unitOfWork.Customers.AddAsync(customer); await _unitOfWork.CompleteAsync(); // Get "Converted" status (cached) var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var statuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId); var convertedStatus = statuses.FirstOrDefault(s => s.StatusCode == "CONVERTED"); // Update quote to link to new customer quote.CustomerId = customer.Id; // Clear prospect fields quote.ProspectCompanyName = null; quote.ProspectContactName = null; quote.ProspectEmail = null; quote.ProspectPhone = null; quote.ProspectAddress = null; quote.ProspectCity = null; quote.ProspectState = null; quote.ProspectZipCode = null; // Update status to converted quote.QuoteStatusId = convertedStatus?.Id ?? quote.QuoteStatusId; await _unitOfWork.Quotes.UpdateAsync(quote); await _unitOfWork.CompleteAsync(); this.ToastSuccess($"Prospect/Walk-In successfully converted to customer! Quote {quote.QuoteNumber} has been updated."); return RedirectToAction("Details", "Customers", new { id = customer.Id }); } catch (Exception ex) { _logger.LogError(ex, "Error converting prospect to customer for quote {QuoteId}", dto.QuoteId); TempData["Error"] = "An error occurred while converting the prospect/walk-in to a customer."; await PopulatePricingTiersDropDownAsync(); return View(dto); } } /// /// Converts an approved quote into a Job, duplicating all line items, coats, prep services, /// and pricing data onto the new Job record. This is the primary workflow for approved quotes. /// The quote's ConvertedToJobId is set so the Details page can link to the job. /// All coat and prep-service relationships are re-loaded with their full navigations before /// being copied — the UoW lazy-load on GetByIdAsync only returns shallow stubs, which would /// cause null reference errors inside if not reloaded here. /// See also for the detailed copy logic and AiPredictionId transfer. /// [HttpPost] [ValidateAntiForgeryToken] public async Task ConvertToJob(int id, string? guidedActivation = null) { try { // Load quote with items and prep services var quote = await _unitOfWork.Quotes.GetByIdAsync(id, false, q => q.QuoteItems, q => q.QuotePrepServices); if (quote == null) { return NotFound(); } // Load coats, prep services with suppliers for each quote item if (quote.QuoteItems != null && quote.QuoteItems.Any()) { foreach (var item in quote.QuoteItems) { var itemWithCoats = await _unitOfWork.QuoteItems.GetByIdAsync(item.Id, false, qi => qi.Coats, qi => qi.PrepServices); if (itemWithCoats?.Coats != null) { item.Coats = itemWithCoats.Coats; } if (itemWithCoats?.PrepServices != null) { item.PrepServices = itemWithCoats.PrepServices; } if (item.Coats != null && item.Coats.Any()) { // Load inventory items and suppliers for each coat foreach (var coat in item.Coats) { // Load inventory item if using in-stock powder if (coat.InventoryItemId.HasValue) { var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(coat.InventoryItemId.Value); if (inventoryItem != null) { coat.InventoryItem = inventoryItem; } } // Load vendor if using custom powder if (coat.VendorId.HasValue) { var vendor = await _unitOfWork.Vendors.GetByIdAsync(coat.VendorId.Value); if (vendor != null) { coat.Vendor = vendor; } } } } } } // Check if already converted if (quote.ConvertedToJobId.HasValue) { TempData["Error"] = "A job has already been created from this quote."; return RedirectToAction(nameof(Details), new { id }); } // Check for an orphaned partial job (previous conversion attempt that failed mid-way). // This can happen if SaveChangesAsync succeeded for the Job row but failed for JobItems. // The unique index on Jobs.QuoteId would block a retry — clean it up first. var orphanedJob = await _unitOfWork.Jobs.GetOrphanedConversionJobAsync(id, quote.CompanyId); if (orphanedJob != null) { _logger.LogWarning("Found orphaned job {JobNumber} (Id={JobId}) from a previous failed conversion of quote {QuoteId}. Cleaning up.", orphanedJob.JobNumber, orphanedJob.Id, id); await _unitOfWork.Jobs.DeleteAsync(orphanedJob); await _unitOfWork.CompleteAsync(); } var currentUser = await _userManager.GetUserAsync(User); // If this is a prospect quote, convert to customer first if (!quote.CustomerId.HasValue) { // Validate prospect has minimum required information if (string.IsNullOrWhiteSpace(quote.ProspectContactName) && string.IsNullOrWhiteSpace(quote.ProspectCompanyName)) { TempData["Error"] = "Prospect/Walk-In must have either a contact name or company name to create a job."; return RedirectToAction(nameof(Details), new { id }); } // Create customer from prospect data var customer = new Customer { CompanyName = quote.ProspectCompanyName, ContactFirstName = quote.ProspectContactName?.Split(' ').FirstOrDefault() ?? "", ContactLastName = quote.ProspectContactName?.Split(' ').Skip(1).FirstOrDefault() ?? "", Email = quote.ProspectEmail ?? "", Phone = quote.ProspectPhone ?? "", Address = quote.ProspectAddress, City = quote.ProspectCity, State = quote.ProspectState, ZipCode = quote.ProspectZipCode, IsCommercial = quote.IsCommercial, CompanyId = currentUser!.CompanyId, CreatedAt = DateTime.UtcNow }; await _unitOfWork.Customers.AddAsync(customer); await _unitOfWork.CompleteAsync(); // Link quote to new customer quote.CustomerId = customer.Id; // Clear prospect fields quote.ProspectCompanyName = null; quote.ProspectContactName = null; quote.ProspectEmail = null; quote.ProspectPhone = null; quote.ProspectAddress = null; quote.ProspectCity = null; quote.ProspectState = null; quote.ProspectZipCode = null; await _unitOfWork.Quotes.UpdateAsync(quote); await _unitOfWork.CompleteAsync(); _logger.LogInformation("Auto-converted prospect to customer {CustomerId} for quote {QuoteId}", customer.Id, quote.Id); } // Create job from quote (manual override - allows any status) await CreateJobFromQuote(quote); this.ToastSuccess($"Job has been successfully created from quote {quote.QuoteNumber}!"); await StampJobCreatedAsync(currentUser!.CompanyId); // Redirect to the newly created job's details page if (quote.ConvertedToJobId.HasValue) { if (guidedActivation == AppConstants.GuidedActivation.QuoteFirstPath) { return RedirectToAction("Details", "Jobs", new { id = quote.ConvertedToJobId.Value, guidedActivation = AppConstants.GuidedActivation.JobCreatedStep }); } return RedirectToAction("Details", "Jobs", new { id = quote.ConvertedToJobId.Value }); } return RedirectToAction(nameof(Details), new { id }); } catch (Exception ex) { _logger.LogError(ex, "Error creating job from quote {QuoteId}", id); TempData["Error"] = "An error occurred while creating a job from this quote."; return RedirectToAction(nameof(Details), new { id }); } } /// /// Shows the quote delete confirmation page with a summary of the quote's items. /// Requires the user to confirm before the actual soft-delete fires via /// . /// public async Task Delete(int? id) { if (id == null) { return NotFound(); } try { var quote = await _unitOfWork.Quotes.GetByIdAsync(id.Value, false, q => q.QuoteStatus); if (quote == null) { return NotFound(); } // Map to DTO for display var quoteDto = _mapper.Map(quote); // Get customer info if exists if (quote.CustomerId.HasValue) { var customer = await _unitOfWork.Customers.GetByIdAsync(quote.CustomerId.Value); if (customer != null) { // For commercial customers, use company name; for individuals, use contact name quoteDto.CustomerName = !string.IsNullOrWhiteSpace(customer.CompanyName) ? customer.CompanyName : $"{customer.ContactFirstName} {customer.ContactLastName}".Trim(); } } var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(id.Value); quoteDto.QuoteItems = _mapper.Map>(quoteItems); // Warn on confirmation page if a job is linked var linkedJob = await _unitOfWork.Jobs.FirstOrDefaultAsync(j => j.QuoteId == id.Value); if (linkedJob != null) { ViewBag.LinkedJobId = linkedJob.Id; ViewBag.LinkedJobNumber = linkedJob.JobNumber; } return View(quoteDto); } catch (Exception ex) { _logger.LogError(ex, "Error loading quote for deletion: {QuoteId}", id); TempData["Error"] = "An error occurred while loading the quote."; return RedirectToAction(nameof(Index)); } } /// /// Soft-deletes a quote. Blocked if a job was created from this quote — the quote /// is the audit trail for how the job was priced and approved. /// [HttpPost, ActionName("Delete")] [ValidateAntiForgeryToken] public async Task DeleteConfirmed(int id) { try { var quote = await _unitOfWork.Quotes.GetByIdAsync(id); if (quote == null) { return NotFound(); } // Block deletion if a job was created from this quote var linkedJob = await _unitOfWork.Jobs.FirstOrDefaultAsync(j => j.QuoteId == id); if (linkedJob != null) { this.ToastError($"Quote {quote.QuoteNumber} cannot be deleted because Job {linkedJob.JobNumber} was created from it. Delete the job first, or keep the quote as a record."); return RedirectToAction(nameof(Details), new { id }); } var quoteNumber = quote.QuoteNumber; await _unitOfWork.Quotes.SoftDeleteAsync(id); await _unitOfWork.CompleteAsync(); this.ToastSuccess($"Quote {quoteNumber} has been deleted."); return RedirectToAction(nameof(Index)); } catch (Exception ex) { _logger.LogError(ex, "Error deleting quote {QuoteId}", id); TempData["Error"] = "An error occurred while deleting the quote."; return RedirectToAction(nameof(Index)); } } /// /// Manually approves a quote on behalf of the customer (internal staff action). /// Sets the quote status to Approved and optionally fires the customer notification email. /// This is distinct from the customer-facing online approval portal /// (QuoteApprovalController) which uses a signed token link sent via email. /// After approval, the Details page surfaces a "Convert to Job" button. /// [HttpPost] [ValidateAntiForgeryToken] public async Task ApproveQuote(int id, bool sendEmail = false) { try { var quote = await _unitOfWork.Quotes.GetByIdAsync(id, false, q => q.QuoteStatus); if (quote == null) { TempData["Error"] = "Quote not found."; return RedirectToAction(nameof(Index)); } // Check if already approved if (quote.QuoteStatus.StatusCode == "APPROVED") { TempData["Info"] = $"Quote {quote.QuoteNumber} is already approved."; return RedirectToAction(nameof(Details), new { id }); } // Store old status for change history var oldStatusName = quote.QuoteStatus.DisplayName; // Get current user's company var currentUser = await _userManager.GetUserAsync(User); // Find the Approved status for this company var approvedStatus = await _unitOfWork.QuoteStatusLookups.FirstOrDefaultAsync( s => s.StatusCode == "APPROVED" && s.CompanyId == currentUser!.CompanyId); if (approvedStatus == null) { TempData["Error"] = "Approved status not found. Please contact support."; return RedirectToAction(nameof(Details), new { id }); } // Update quote status quote.QuoteStatusId = approvedStatus.Id; quote.ApprovedDate = DateTime.UtcNow; quote.UpdatedAt = DateTime.UtcNow; // If previously declined by customer, clear the decline reason now that it's approved if (!string.IsNullOrWhiteSpace(quote.DeclineReason)) quote.DeclineReason = null; await _unitOfWork.Quotes.UpdateAsync(quote); // Add change history entry var changeHistory = new QuoteChangeHistory { QuoteId = quote.Id, ChangedByUserId = currentUser!.Id, ChangedAt = DateTime.UtcNow, FieldName = "Status", OldValue = oldStatusName, NewValue = approvedStatus.DisplayName, ChangeDescription = $"Quote approved by {currentUser.FirstName} {currentUser.LastName}", CompanyId = quote.CompanyId, CreatedAt = DateTime.UtcNow }; await _unitOfWork.QuoteChangeHistories.AddAsync(changeHistory); await _unitOfWork.CompleteAsync(); _logger.LogInformation("Quote {QuoteId} approved by user {UserId}", id, currentUser.Id); // Notify customer that quote is approved (only if user opted in) if (sendEmail) { try { var quoteForNotify = await _unitOfWork.Quotes.GetByIdAsync(id, false, q => q.Customer); if (quoteForNotify != null) await _notificationService.NotifyQuoteApprovedAsync(quoteForNotify); } catch (Exception ex) { _logger.LogWarning(ex, "Notification failed for quote {Id}", id); } var approveNotifLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(id); this.SetNotificationResultToast(approveNotifLog); } // If this is a prospect quote, redirect straight to conversion so the // prospect doesn't linger without becoming a customer. if (!quote.CustomerId.HasValue) { this.ToastSuccess($"Quote {quote.QuoteNumber} approved! Convert the prospect/walk-in to a customer below."); return RedirectToAction(nameof(ConvertToCustomer), new { id }); } this.ToastSuccess($"Quote {quote.QuoteNumber} has been approved!"); return RedirectToAction(nameof(Details), new { id }); } catch (Exception ex) { _logger.LogError(ex, "Error approving quote {QuoteId}", id); TempData["Error"] = "An error occurred while approving the quote."; return RedirectToAction(nameof(Details), new { id }); } } /// /// AJAX endpoint that calculates the full pricing breakdown for the quote wizard in real time. /// Called by item-wizard.js whenever the user adds/removes items or changes quantities. /// Returns a JSON payload that the wizard uses to update all pricing fields without a page reload. /// If a named oven (OvenCostId) is selected its rate overrides the default from CompanyOperatingCosts /// so the quote reflects the actual oven being scheduled, not the blended average rate. /// [HttpPost] public async Task CalculatePricing([FromBody] PricingCalculationRequest request) { try { if (request == null) return BadRequest(new { error = "Invalid request payload." }); var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) { return Unauthorized(); } // Resolve oven rate override from selected oven cost decimal? ovenRateOverride = null; if (request.OvenCostId.HasValue) { var ovenCost = await _unitOfWork.OvenCosts.GetByIdAsync(request.OvenCostId.Value); if (ovenCost != null && ovenCost.IsActive && ovenCost.CompanyId == currentUser.CompanyId) { ovenRateOverride = ovenCost.CostPerHour; } } var pricingResult = await _pricingService.CalculateQuoteTotalsAsync( request.Items, currentUser.CompanyId, request.CustomerId, request.TaxPercent, request.DiscountType, request.DiscountValue, request.IsRushJob, ovenRateOverride, request.OvenBatches, request.OvenCycleMinutes ); return Json(pricingResult); } catch (Exception ex) { _logger.LogError(ex, "Error calculating pricing"); return StatusCode(500, new { error = "An error occurred while calculating pricing." }); } } // Helper method to populate customers dropdown /// /// Loads company email notification preferences and stores defaults in ViewBag. /// ViewBag.EmailDefaultOnApprove — default checked state for quote-approved emails /// private async Task PopulateEmailNotificationDefaultsAsync(int companyId) { var prefs = (await _unitOfWork.CompanyPreferences.FindAsync(p => p.CompanyId == companyId)).FirstOrDefault(); var emailOn = prefs?.EmailNotificationsEnabled ?? true; ViewBag.EmailDefaultOnApprove = emailOn && (prefs?.NotifyOnQuoteApproval ?? true); } /// /// Populates all ViewBag data needed to render the Create/Edit quote wizard dropdowns and feature flags. /// The is used when no named oven is selected — it comes from /// CompanyOperatingCosts. Tax-exempt customer IDs are passed as a JSON set so the wizard's JS can /// automatically zero out the TaxPercent field when the user selects an exempt customer, and restore /// the company default when they switch back to a taxable customer. /// private async Task PopulateDropDownsAsync(int companyId, decimal fallbackOvenRate) { ViewBag.AiPhotoQuotesEnabled = await _subscriptionService.CanUseAiPhotoQuoteAsync(companyId); var (_, quotePhotoMax) = await _subscriptionService.GetQuotePhotoCountAsync(companyId, 0); ViewBag.QuotePhotosEnabled = quotePhotoMax != 0; // 0 = feature disabled for this plan // Customers var customers = await _unitOfWork.Customers.GetAllAsync(); ViewBag.Customers = customers .Select(c => new SelectListItem { Value = c.Id.ToString(), Text = (!string.IsNullOrWhiteSpace(c.CompanyName) ? c.CompanyName : $"{c.ContactFirstName} {c.ContactLastName}".Trim()) + (c.IsTaxExempt ? " ★" : "") }) .OrderBy(c => c.Text) .ToList(); // Map used by JS to zero tax when a tax-exempt customer is selected ViewBag.CustomerTaxExemptIds = customers .Where(c => c.IsTaxExempt) .Select(c => c.Id) .ToHashSet(); // Map used by JS to disable the email checkbox when the customer has notifications turned off ViewBag.CustomerEmailOptOutIds = customers .Where(c => !c.NotifyByEmail) .Select(c => c.Id) .ToHashSet(); // Stored separately so views can restore the company default when switching away from an exempt customer // (ViewBag.CompanyTaxPercent is set by the calling action if it has access to operatingCosts) if (ViewBag.CompanyTaxPercent == null && customers.Any()) { var costs = await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == companyId); ViewBag.CompanyTaxPercent = costs.FirstOrDefault()?.TaxPercent ?? 0; } // Inventory coatings var inventory = await _unitOfWork.InventoryItems.GetAllAsync(false, i => i.InventoryCategory); ViewBag.InventoryCoatings = inventory .Where(i => i.IsActive && i.InventoryCategory?.IsActive == true && i.InventoryCategory.IsCoating) .OrderBy(i => i.InventoryCategory!.DisplayOrder).ThenBy(i => i.ColorName ?? i.Name) .Select(i => new { value = i.Id.ToString(), text = $"{i.InventoryCategory!.DisplayName} - {i.Manufacturer ?? "Generic"} - {i.ColorName ?? i.Name} - {i.ColorCode ?? "N/A"} ({i.UnitCost:C4}/unit)", coverage = i.CoverageSqFtPerLb ?? 30m, efficiency = i.TransferEfficiency ?? 65m, unitOfMeasure = i.UnitOfMeasure ?? "lbs", categoryName = i.InventoryCategory.DisplayName, costPerLb = i.UnitCost, colorName = i.ColorName ?? i.Name, colorCode = i.ColorCode ?? "" }).ToList(); // Vendors var vendors = await _unitOfWork.Vendors.GetAllAsync(false); ViewBag.Vendors = vendors .Where(s => s.IsActive).OrderBy(s => s.CompanyName) .Select(s => new { value = s.Id.ToString(), text = s.CompanyName }).ToList(); // Catalog items var catalogItems = await _unitOfWork.CatalogItems.GetAllAsync(false, i => i.Category, i => i.Category.ParentCategory); ViewBag.CatalogItems = catalogItems .Where(i => i.IsActive) .OrderBy(i => i.Category.DisplayOrder).ThenBy(i => i.DisplayOrder) .Select(i => new { value = i.Id.ToString(), text = BuildCatalogItemDisplayText(i), categoryName = i.Category.Name, price = i.DefaultPrice, approxArea = i.ApproximateArea ?? 0m, defaultMinutes = i.DefaultEstimatedMinutes ?? 0, thumbnailPath = i.ThumbnailPath }).ToList(); // Merchandise items (IsMerchandise = true) — for the sales wizard step ViewBag.MerchandiseItems = catalogItems .Where(i => i.IsActive && i.IsMerchandise) .OrderBy(i => i.Category.Name).ThenBy(i => i.DisplayOrder).ThenBy(i => i.Name) .Select(i => new { id = i.Id, name = i.Name, sku = i.SKU, category = i.Category.Name, price = i.DefaultPrice, description = i.Description }).ToList(); // Prep services var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive); ViewBag.PrepServices = prepServices.OrderBy(ps => ps.DisplayOrder).ToList(); // Blast setups for wizard dropdown var blastSetups = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive); ViewBag.BlastSetups = blastSetups.OrderBy(b => b.DisplayOrder) .Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault }) .ToList(); // Quote statuses var quoteStatuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId); ViewBag.QuoteStatuses = new SelectList( quoteStatuses.Where(s => s.IsActive).OrderBy(s => s.DisplayOrder), "Id", "DisplayName"); // Oven costs var ovens = await _unitOfWork.OvenCosts.FindAsync(o => o.IsActive && o.CompanyId == companyId); var ovenItems = new List { new SelectListItem { Value = "", Text = $"Default rate ({fallbackOvenRate:C2}/hr)" } }; ovenItems.AddRange(ovens.OrderBy(o => o.DisplayOrder).ThenBy(o => o.Label) .Select(o => new SelectListItem { Value = o.Id.ToString(), Text = $"{o.Label} ({o.CostPerHour:C2}/hr)" })); ViewBag.OvenCosts = ovenItems; } /// /// Sets ViewBag fields for the measurement unit display (sq ft vs m²) and complexity /// surcharge percentages so the wizard can show live unit labels and calculate the /// complexity adjustment without a server round-trip. /// private async Task SetMeasurementViewBagAsync() { var useMetric = await _tenantContext.UseMetricSystemAsync(); ViewBag.UseMetric = useMetric; ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric); ViewBag.CoverageUnit = _measurementService.GetCoverageUnitLabel(useMetric); var currentUser = await _userManager.GetUserAsync(User); if (currentUser != null) { var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId); ViewBag.ComplexitySimplePercent = costs?.ComplexitySimplePercent ?? 0m; ViewBag.ComplexityModeratePercent = costs?.ComplexityModeratePercent ?? 5m; ViewBag.ComplexityComplexPercent = costs?.ComplexityComplexPercent ?? 15m; ViewBag.ComplexityExtremePercent = costs?.ComplexityExtremePercent ?? 25m; } } /// /// Loads the default payment terms text from CompanyPreferences and the default oven cycle /// duration from CompanyOperatingCosts into ViewBag so the wizard can pre-fill those fields. /// IgnoreQueryFilters is used so the prefs row is always found even if the tenant filter /// would otherwise exclude it in edge-case setups. /// private async Task SetDefaultTermsAsync(int companyId) { var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync( p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true); ViewBag.DefaultTerms = prefs?.QtDefaultTerms; var costs = await _pricingService.GetOperatingCostsAsync(companyId); ViewBag.DefaultOvenCycleMinutes = costs?.DefaultOvenCycleMinutes ?? 45; } /// /// Populates the pricing tier dropdown for the quote wizard. /// Displays each tier as "Tier Name (X% discount)" so the salesperson can see the discount /// impact when selecting a tier for a customer. /// private async Task PopulatePricingTiersDropDownAsync() { var pricingTiers = await _unitOfWork.PricingTiers.GetAllAsync(); ViewBag.PricingTiers = pricingTiers.OrderBy(pt => pt.TierName) .Select(pt => new SelectListItem { Value = pt.Id.ToString(), Text = $"{pt.TierName} ({pt.DiscountPercent}% discount)" }).ToList(); } /// /// Builds the full hierarchical display label for a catalog item in the wizard dropdown. /// Traverses the category tree bottom-up to produce "Category > Subcategory > Item Name [SKU] - $Price". /// This lets the user identify the correct item even when multiple items share the same name /// but live in different catalog categories. /// private string BuildCatalogItemDisplayText(CatalogItem item) { var categoryPath = new List(); var currentCategory = item.Category; // Build category path from bottom to top while (currentCategory != null) { categoryPath.Insert(0, currentCategory.Name); currentCategory = currentCategory.ParentCategory; } // Format: "Category > Subcategory > Item Name - SKU - $Price" var hierarchy = string.Join(" > ", categoryPath); var sku = !string.IsNullOrWhiteSpace(item.SKU) ? $" [{item.SKU}]" : ""; return $"{hierarchy} > {item.Name}{sku} - {item.DefaultPrice:C}"; } /// /// Generates the raw PDF bytes for a quote using QuestPDF. Shared by both the /// action and the email-send flow so the PDF is always /// built the same way regardless of how it's delivered. Loads the quote, its items, /// coats, company branding, and operating costs before calling . /// private async Task BuildQuotePdfAsync(int quoteId, ApplicationUser currentUser) { var quote = await _unitOfWork.Quotes.GetByIdAsync(quoteId); if (quote == null) throw new InvalidOperationException($"Quote {quoteId} not found."); var quoteDto = _mapper.Map(quote); if (quote.CustomerId.HasValue) { var customer = await _unitOfWork.Customers.GetByIdAsync(quote.CustomerId.Value); if (customer != null) { quoteDto.CustomerCompanyName = customer.CompanyName; quoteDto.CustomerContactFirstName = customer.ContactFirstName; quoteDto.CustomerContactLastName = customer.ContactLastName; quoteDto.CustomerEmail = customer.Email; quoteDto.CustomerPhone = customer.Phone; } } var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(quoteId); quoteDto.QuoteItems = _mapper.Map>(quoteItems); var company = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId); if (company == null) throw new InvalidOperationException("Company not found."); var companyInfo = new Application.DTOs.Company.CompanyInfoDto { CompanyName = company.CompanyName, Phone = company.Phone, Address = company.Address, City = company.City, State = company.State, ZipCode = company.ZipCode, PrimaryContactEmail = company.PrimaryContactEmail }; var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync( p => p.CompanyId == currentUser.CompanyId && !p.IsDeleted, ignoreQueryFilters: true); var template = new Application.DTOs.Company.QuoteTemplateSettingsDto { AccentColor = prefs?.QtAccentColor ?? "#374151", FooterNote = prefs?.QtFooterNote, DefaultTerms = prefs?.QtDefaultTerms }; return await _pdfService.GenerateQuotePdfAsync( quoteDto, company.LogoData, company.LogoContentType, companyInfo, template: template); } /// /// Generates the next sequential quote number in the format PREFIX-YYMM-#### (e.g. QT-2404-0001). /// The prefix is taken from CompanyPreferences.QuoteNumberPrefix (defaults to "QT"). /// IgnoreQueryFilters is used so the query always works regardless of tenant context edge cases. /// The sequence resets each month — it's a per-company counter scoped to year+month. /// private async Task GenerateQuoteNumberAsync() { var now = DateTime.UtcNow; var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync( p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true); var quotePrefix = !string.IsNullOrWhiteSpace(prefs?.QuoteNumberPrefix) ? prefs.QuoteNumberPrefix : "QT"; var prefix = $"{quotePrefix}-{now:yy}{now:MM}"; // IgnoreQueryFilters so soft-deleted quotes are counted (prevents number reuse) var lastQuoteNumber = await _unitOfWork.Quotes.GetLastQuoteNumberByPrefixAsync(companyId, prefix); int nextNumber = 1; if (lastQuoteNumber != null) { var lastNumberStr = lastQuoteNumber.Substring(prefix.Length + 1); if (int.TryParse(lastNumberStr, out var lastNumber)) nextNumber = lastNumber + 1; } _logger.LogInformation("Generated quote number: {QuoteNumber}", $"{prefix}-{nextNumber:D4}"); return $"{prefix}-{nextNumber:D4}"; } /// /// AJAX endpoint that transitions a quote to a new status without a full page reload. /// Used by the status-change dropdown on the Details page. When the new status is "Approved" /// the quote is automatically converted to a job via so that /// a single click can approve and create the job simultaneously (the auto-approve workflow). /// Records a entry for every status change. /// [HttpPost] [ValidateAntiForgeryToken] public async Task UpdateQuoteStatus([FromBody] UpdateQuoteStatusRequest request) { try { // Get quote with items, status, and prep services for job creation var quote = await _unitOfWork.Quotes.GetByIdAsync(request.QuoteId, false, q => q.QuoteItems, q => q.QuoteStatus, q => q.QuotePrepServices); if (quote == null) { return Json(new { success = false, message = "Quote not found" }); } // Get new status var newStatus = await _unitOfWork.QuoteStatusLookups.GetByIdAsync(request.StatusId); if (newStatus == null) { return Json(new { success = false, message = "Invalid status" }); } var oldStatusCode = quote.QuoteStatus.StatusCode; quote.QuoteStatusId = request.StatusId; quote.UpdatedAt = DateTime.UtcNow; // Set approved date when status changes to Approved if (newStatus.StatusCode == "APPROVED" && oldStatusCode != "APPROVED") { quote.ApprovedDate = DateTime.UtcNow; } await _unitOfWork.Quotes.UpdateAsync(quote); await _unitOfWork.SaveChangesAsync(); // Auto-create job when quote is approved — guard against double-conversion // (race condition: two simultaneous approval calls could both pass the oldStatusCode check) if (newStatus.StatusCode == "APPROVED" && oldStatusCode != "APPROVED" && !quote.ConvertedToJobId.HasValue) { try { await CreateJobFromQuote(quote); } catch (Exception jobEx) { _logger.LogError(jobEx, "Error creating job from approved quote {QuoteId}", request.QuoteId); // Don't fail the status update if job creation fails return Json(new { success = true, status = newStatus.DisplayName, warning = "Quote approved but job creation failed. Please create the job manually." }); } } return Json(new { success = true, status = newStatus.DisplayName }); } catch (Exception ex) { _logger.LogError(ex, "Error updating status for quote {QuoteId}", request.QuoteId); return Json(new { success = false, message = "An error occurred while updating the status" }); } } /// /// Creates a Job from an approved Quote, copying all line items, coats, prep services, /// pricing totals, and the AiPredictionId FK from each QuoteItem to the corresponding JobItem /// (no new prediction records are created — they share the same row for reporting purposes). /// Runs inside a DB transaction so the job and all its children are committed atomically. /// Quote items are always reloaded with full coats/prep-service navigations here, regardless /// of what the caller may or may not have loaded — this prevents null-ref bugs when called /// from the auto-approve path in UpdateQuoteStatus (which may only have shallow items). /// Rush quotes carry their priority through to the job (Rush priority lookup code = "RUSH"). /// private async Task CreateJobFromQuote(Quote quote) { _logger.LogInformation("Auto-creating job from approved quote {QuoteId}", quote.Id); Job? job = null; await _unitOfWork.ExecuteInTransactionAsync(async () => { // Always reload quote items with full coat/prep-service data so this works // regardless of which caller loaded the quote (some callers don't include coats). var fullItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(quote.Id); // Do NOT assign fullItems to quote.QuoteItems — quote is a tracked entity and assigning // no-tracking children (which may share InventoryItem instances) causes EF identity conflicts. // Get default job statuses and priorities var jobStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync(); var jobPriorities = await _unitOfWork.JobPriorityLookups.GetAllAsync(); var approvedStatus = jobStatuses.FirstOrDefault(s => s.StatusCode == "APPROVED"); var normalPriority = jobPriorities.FirstOrDefault(p => p.PriorityCode == "NORMAL"); var rushPriority = jobPriorities.FirstOrDefault(p => p.PriorityCode == "RUSH"); // Use Rush priority if quote is a rush job, otherwise use Normal var selectedPriority = quote.IsRushJob && rushPriority != null ? rushPriority : normalPriority; job = new Job { JobNumber = await GenerateJobNumberAsync(), CustomerId = quote.CustomerId ?? 0, // Should always have a customer by approval time QuoteId = quote.Id, OvenCostId = quote.OvenCostId, // Carry oven selection from quote Description = quote.Description ?? $"Job from Quote {quote.QuoteNumber}", JobStatusId = approvedStatus?.Id ?? 1, JobPriorityId = selectedPriority?.Id ?? 1, QuotedPrice = quote.Total, FinalPrice = quote.Total, CustomerPO = quote.CustomerPO, InternalNotes = quote.Notes, // Copy internal notes from quote IsCustomerApproved = true, IsRushJob = quote.IsRushJob, DiscountType = quote.DiscountType, DiscountValue = quote.DiscountValue, DiscountReason = quote.DiscountReason, CompanyId = quote.CompanyId, CreatedAt = DateTime.UtcNow, QuoteSnapshotUpdatedAt = quote.UpdatedAt ?? quote.CreatedAt }; await _unitOfWork.Jobs.AddAsync(job); await _unitOfWork.SaveChangesAsync(); // Create job items from quote items foreach (var quoteItem in fullItems.Where(qi => !qi.IsDeleted)) { // Get first coat's color information if available var firstCoat = quoteItem.Coats?.OrderBy(c => c.Sequence).FirstOrDefault(); var jobItem = new JobItem { JobId = job.Id, Description = quoteItem.Description, Quantity = quoteItem.Quantity, ColorName = firstCoat?.ColorName, ColorCode = firstCoat?.ColorCode, Finish = firstCoat?.Finish, SurfaceArea = quoteItem.SurfaceAreaSqFt, SurfaceAreaSqFt = quoteItem.SurfaceAreaSqFt, CatalogItemId = quoteItem.CatalogItemId, IsGenericItem = quoteItem.IsGenericItem, IsLaborItem = quoteItem.IsLaborItem, IsSalesItem = quoteItem.IsSalesItem, Sku = quoteItem.Sku, ManualUnitPrice = quoteItem.ManualUnitPrice, PowderCostOverride = quoteItem.PowderCostOverride, UnitPrice = quoteItem.UnitPrice, TotalPrice = quoteItem.TotalPrice, LaborCost = quoteItem.TotalPrice * 0.4m, // Estimated 40% labor cost RequiresSandblasting = quoteItem.RequiresSandblasting, RequiresMasking = quoteItem.RequiresMasking, EstimatedMinutes = quoteItem.EstimatedMinutes, Notes = quoteItem.Notes, Complexity = quoteItem.Complexity, AiTags = quoteItem.AiTags, AiPredictionId = quoteItem.AiPredictionId, // Share the same prediction record — no duplication // Catalog items are fixed-price — prep services must not add labor cost to them. // Non-catalog items default to true so prep service labor is included in the calculated price. IncludePrepCost = !quoteItem.CatalogItemId.HasValue, CompanyId = quote.CompanyId, CreatedAt = DateTime.UtcNow }; await _unitOfWork.JobItems.AddAsync(jobItem); await _unitOfWork.SaveChangesAsync(); // Save JobItem first to get its ID // Create JobItemCoat records for all coats from quote if (quoteItem.Coats != null && quoteItem.Coats.Any()) { foreach (var quoteCoat in quoteItem.Coats.OrderBy(c => c.Sequence)) { // Get color info from inventory item if available, otherwise use coat fields string colorName = quoteCoat.ColorName; string colorCode = quoteCoat.ColorCode; string finish = quoteCoat.Finish; if (quoteCoat.InventoryItemId.HasValue && quoteCoat.InventoryItem != null) { // Use inventory item information (takes precedence) colorName = quoteCoat.InventoryItem.Name; colorCode = quoteCoat.InventoryItem.ColorCode; finish = quoteCoat.InventoryItem.Finish; } // Calculate PowderToOrder if not already stored on the quote coat var cov = quoteCoat.CoverageSqFtPerLb > 0 ? quoteCoat.CoverageSqFtPerLb : 30m; var eff = quoteCoat.TransferEfficiency > 0 ? quoteCoat.TransferEfficiency / 100m : 0.65m; var powderToOrder = (quoteCoat.PowderToOrder > 0) ? quoteCoat.PowderToOrder : (quoteItem.SurfaceAreaSqFt > 0 ? Math.Round((quoteItem.SurfaceAreaSqFt * quoteItem.Quantity) / (cov * eff), 2) : (decimal?)null); var jobCoat = new JobItemCoat { JobItemId = jobItem.Id, CoatName = quoteCoat.CoatName, Sequence = quoteCoat.Sequence, InventoryItemId = quoteCoat.InventoryItemId, ColorName = colorName, VendorId = quoteCoat.VendorId, ColorCode = colorCode, Finish = finish, CoverageSqFtPerLb = quoteCoat.CoverageSqFtPerLb, TransferEfficiency = quoteCoat.TransferEfficiency, PowderCostPerLb = quoteCoat.PowderCostPerLb, PowderToOrder = powderToOrder, Notes = quoteCoat.Notes, CompanyId = quote.CompanyId, CreatedAt = DateTime.UtcNow }; await _unitOfWork.JobItemCoats.AddAsync(jobCoat); _logger.LogInformation("Created JobItemCoat '{CoatName}' (Sequence {Seq}) - Color: {Color} ({Code})", jobCoat.CoatName, jobCoat.Sequence, colorName ?? "N/A", colorCode ?? "N/A"); } } } await _unitOfWork.SaveChangesAsync(); // Aggregate unique prep services from all quote items and copy to job // Load from DB directly to ensure prep services are available regardless of caller's includes var quoteItemIds = fullItems.Select(qi => qi.Id).ToList(); var itemPrepServices = (await _unitOfWork.QuoteItemPrepServices.FindAsync( ps => quoteItemIds.Contains(ps.QuoteItemId))).ToList(); var uniquePrepServiceIds = itemPrepServices .Select(ps => ps.PrepServiceId) .Distinct() .ToList(); if (uniquePrepServiceIds.Any()) { foreach (var prepServiceId in uniquePrepServiceIds) { await _unitOfWork.JobPrepServices.AddAsync(new JobPrepService { JobId = job.Id, PrepServiceId = prepServiceId, CompanyId = quote.CompanyId, CreatedAt = DateTime.UtcNow }); } await _unitOfWork.SaveChangesAsync(); _logger.LogInformation("Copied {Count} unique prep services to job {JobNumber}", uniquePrepServiceIds.Count, job.JobNumber); } // Update quote to track the conversion quote.ConvertedToJobId = job.Id; quote.ConvertedDate = DateTime.UtcNow; await _unitOfWork.SaveChangesAsync(); // Copy all quote photos to job (leave originals on the quote) // AI analysis photos are copied with IsAiAnalysisPhoto=true so they don't count against subscription limits try { var quotePhotos = (await _unitOfWork.QuotePhotos.FindAsync( p => p.QuoteId == quote.Id && !p.IsDeleted, ignoreQueryFilters: true)).ToList(); foreach (var qp in quotePhotos) { var (readOk, photoBytes, _) = await _photoService.ReadPhotoAsync(qp.FilePath); if (!readOk || photoBytes.Length == 0) continue; using var ms = new MemoryStream(photoBytes); var formFile = new FormFile(ms, 0, photoBytes.Length, "photo", qp.FileName) { Headers = new HeaderDictionary(), ContentType = qp.ContentType }; var caption = qp.IsAiAnalysisPhoto ? "AI Quote Photo" : "Quote Photo"; var (saved, jobPhotoPath, _) = await _jobPhotoService.SaveJobPhotoAsync( formFile, job.Id, quote.CompanyId, caption, JobPhotoType.Before); if (saved) { await _unitOfWork.JobPhotos.AddAsync(new JobPhoto { JobId = job.Id, CompanyId = quote.CompanyId, FilePath = jobPhotoPath, FileName = qp.FileName, FileSize = qp.FileSize, ContentType = qp.ContentType, Caption = caption, PhotoType = JobPhotoType.Before, IsAiAnalysisPhoto = qp.IsAiAnalysisPhoto, UploadedById = qp.UploadedById ?? string.Empty, CreatedAt = DateTime.UtcNow }); } } await _unitOfWork.SaveChangesAsync(); } catch (Exception photoEx) { _logger.LogWarning(photoEx, "Failed to copy quote photos to job {JobNumber} (non-fatal)", job.JobNumber); } }); // end ExecuteInTransactionAsync _logger.LogInformation("Successfully created job {JobNumber} from quote {QuoteNumber}", job.JobNumber, quote.QuoteNumber); } /// /// Generates the next sequential job number in the format PREFIX-YYMM-#### (e.g. JOB-2404-0001). /// Mirrors but uses the JobNumberPrefix preference. /// Called from so the job number is assigned at conversion time. /// private async Task GenerateJobNumberAsync() { var year = DateTime.Now.Year.ToString().Substring(2); var month = DateTime.Now.Month.ToString("D2"); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync( p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true); var jobPrefix = !string.IsNullOrWhiteSpace(prefs?.JobNumberPrefix) ? prefs.JobNumberPrefix : "JOB"; var prefix = $"{jobPrefix}-{year}{month}"; // IgnoreQueryFilters so soft-deleted jobs are counted (prevents number reuse) var lastJobNumber = await _unitOfWork.Jobs.GetLastJobNumberByPrefixAsync(companyId, prefix); int nextNumber = 1; if (lastJobNumber != null) { var lastNumberStr = lastJobNumber.Substring(prefix.Length + 1); if (int.TryParse(lastNumberStr, out int lastNumber)) nextNumber = lastNumber + 1; } return $"{prefix}-{nextNumber:D4}"; } /// /// AJAX endpoint that emails the quote PDF to the customer (or prospect). /// Attaches the PDF generated by and records the /// notification in so the Notifications Sent page can /// show delivery history. Works for both linked customers and prospect quotes /// (falls back to ProspectEmail when no CustomerId is set). /// [HttpPost] [ValidateAntiForgeryToken] public async Task ResendQuote(int id) { try { var quote = await _unitOfWork.Quotes.GetByIdAsync(id, false, q => q.Customer, q => q.QuoteStatus); if (quote == null) return Json(new { success = false, message = "Quote not found." }); // Determine recipient for feedback message string? recipientEmail = quote.CustomerId.HasValue ? quote.Customer?.Email : quote.ProspectEmail; string recipientName = quote.CustomerId.HasValue && quote.Customer != null ? (!string.IsNullOrWhiteSpace(quote.Customer.CompanyName) ? quote.Customer.CompanyName : $"{quote.Customer.ContactFirstName} {quote.Customer.ContactLastName}".Trim()) : (!string.IsNullOrWhiteSpace(quote.ProspectContactName) ? quote.ProspectContactName : quote.ProspectCompanyName ?? "Prospect"); if (string.IsNullOrWhiteSpace(recipientEmail)) return Json(new { success = false, message = "No email address on file for this quote's recipient." }); var currentUser = await _userManager.GetUserAsync(User); // Generate PDF attachment byte[]? pdfBytes = null; string? pdfFilename = null; try { pdfBytes = await BuildQuotePdfAsync(id, currentUser); pdfFilename = $"Quote-{quote.QuoteNumber}.pdf"; } catch (Exception pdfEx) { _logger.LogWarning(pdfEx, "PDF generation failed for resend of quote {Id}; sending without attachment", id); } // Regenerate approval token (invalidates old link) var resendTokenBytes = System.Security.Cryptography.RandomNumberGenerator.GetBytes(32); quote.ApprovalToken = Convert.ToBase64String(resendTokenBytes) .Replace('+', '-').Replace('/', '_').TrimEnd('='); quote.ApprovalTokenExpiresAt = DateTime.UtcNow.AddDays( int.TryParse(await _platformSettings.GetAsync(PlatformSettingKeys.QuoteApprovalTokenDays), out var tokenDays) ? tokenDays : 30); quote.ApprovalTokenUsedAt = null; await _unitOfWork.Quotes.UpdateAsync(quote); await _unitOfWork.CompleteAsync(); await _notificationService.NotifyQuoteSentAsync(quote, pdfBytes, pdfFilename); // Check the most recent log entry to get actual send status var latestLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(id); if (latestLog?.Status == NotificationStatus.Failed) return Json(new { success = false, message = $"Email delivery failed: {latestLog.ErrorMessage}" }); if (latestLog?.Status == NotificationStatus.Skipped) return Json(new { success = false, message = $"{recipientName} has email notifications disabled or no email address on file." }); return Json(new { success = true, message = $"Quote sent to {recipientName} ({recipientEmail})." }); } catch (Exception ex) { _logger.LogError(ex, "Error resending quote {QuoteId}", id); return Json(new { success = false, message = "An unexpected error occurred. Please try again." }); } } /// /// Returns the notification delivery history for a quote as a JSON array. /// Used by the "Notifications Sent" tab on the Details page to show which emails/SMS /// were sent, when, and whether they succeeded. IgnoreQueryFilters is used so SuperAdmins /// viewing cross-tenant quotes can also see the log. /// [HttpGet] public async Task NotificationsSent(int id) { var tz = ViewBag.CompanyTimeZone as string; var rawLogs = await _unitOfWork.NotificationLogs.GetAllForQuoteAsync(id); var logs = rawLogs.Select(n => new { n.Id, Channel = n.Channel.ToString(), Type = n.NotificationType.ToString(), Status = n.Status.ToString(), n.RecipientName, n.Recipient, n.Subject, n.ErrorMessage, SentAt = n.SentAt.Tz(tz).ToString("MMM d, yyyy h:mm tt") }); return Json(logs); } // ── AI Photo Quote Endpoints ──────────────────────────────────────────────── /// Serve a quote photo by ID (photos are stored outside wwwroot). [HttpGet] public async Task Photo(int id) { var photo = await _unitOfWork.QuotePhotos.GetByIdAsync(id); if (photo == null) return NotFound(); var (success, data, contentType) = await _photoService.ReadPhotoAsync(photo.FilePath); if (!success) return NotFound(); return File(data, contentType); } /// /// Uploads a single photo to temporary storage as part of the AI Photo Quote wizard flow. /// Photos are saved to media/temp/{tempId}/ and are NOT yet linked to any quote — /// they are promoted to a permanent path in the Create POST via . /// This two-phase approach allows the wizard to accept photos before a quote ID exists. /// Returns a tempId that the JS wizard tracks and submits as AiPhotoTempIds[] on form submit. /// [HttpPost] [ValidateAntiForgeryToken] [EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)] public async Task UploadAiPhoto(IFormFile? file) { if (file == null || file.Length == 0) return Json(new { success = false, error = "No file provided." }); var user = await _userManager.GetUserAsync(User); var companyId = user?.CompanyId ?? 0; if (!await _subscriptionService.CanUseAiPhotoQuoteAsync(companyId)) return Json(new { success = false, error = "AI Photo Quotes are not available for your subscription. Contact your administrator." }); var (success, tempId, _, error) = await _photoService.SaveTempPhotoAsync(file, companyId); if (!success) return Json(new { success = false, error }); return Json(new { success = true, tempId, fileName = file.FileName, fileSize = file.Length, fileSizeDisplay = FormatFileSize(file.Length) }); } /// /// AJAX endpoint that sends uploaded photos to Anthropic Claude Sonnet 4.6 for analysis /// and returns a structured estimate (surface area, complexity, estimated minutes, confidence). /// Enforces the monthly AI Photo Quote usage limit before calling the API. /// Up to 2 follow-up conversation rounds are supported — each round sends the previous messages /// and the user's clarification back to Claude so it can refine its estimate. /// On the final accepted result, saves an row for ML benchmarking /// and accuracy reporting. Injects company AI profile context via /// so the model's estimates are calibrated to this company's pricing and material usage patterns. /// [HttpPost] [ValidateAntiForgeryToken] [EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)] public async Task AiAnalyzeItem([FromBody] AiAnalyzeItemRequest request) { try { var user = await _userManager.GetUserAsync(User); var companyId = user?.CompanyId ?? 0; if (!await _subscriptionService.CanUseAiPhotoQuoteAsync(companyId)) { var (used, max) = await _subscriptionService.GetAiPhotoQuoteUsageAsync(companyId); var limitMsg = max == 0 ? "AI Photo Quotes are not enabled for your account." : $"You have reached your monthly AI Photo Quote limit ({used}/{max}). Resets on the 1st of next month."; return Json(new AiAnalyzeItemResult { Success = false, ErrorMessage = limitMsg }); } request.CompanyId = companyId; // Read temp photos from blob storage var photos = new List<(byte[] Data, string ContentType, string FileName)>(); foreach (var rawTempId in request.PhotoTempIds) { // Validate tempId is a well-formed GUID to prevent path traversal if (!Guid.TryParse(rawTempId, out var tempGuid)) continue; var tempId = tempGuid.ToString("N"); // match the N-format used when saving var tempPhotos = await _photoService.ReadTempPhotosAsync(tempId); photos.AddRange(tempPhotos); } if (photos.Count == 0) return Json(new AiAnalyzeItemResult { Success = false, ErrorMessage = "No photos found. Please upload at least one photo." }); var costs = await _pricingService.GetOperatingCostsAsync(companyId); if (costs == null) return Json(new AiAnalyzeItemResult { Success = false, ErrorMessage = "Company operating costs not configured." }); // Average powder cost from inventory (fallback $8/lb) decimal avgPowderCost; try { var powders = await _unitOfWork.InventoryItems.FindAsync(i => i.Category != null && i.Category.ToLower().Contains("powder") && i.UnitCost > 0); avgPowderCost = powders.Any() ? powders.Average(p => p.UnitCost) : 8m; } catch { avgPowderCost = 8m; // fallback if query fails } // Build company AI context: profile text + recent accepted predictions as few-shot examples var aiContext = await BuildCompanyAiContextAsync(companyId, costs); // Load the specific blast setup when the user picked one before analyzing CompanyBlastSetup? selectedBlastSetup = null; if (request.BlastSetupId.HasValue) { var setups = await _unitOfWork.BlastSetups.FindAsync(b => b.Id == request.BlastSetupId.Value && b.IsActive); selectedBlastSetup = setups.FirstOrDefault(); } var result = await _aiService.AnalyzeItemAsync(request, photos, costs, avgPowderCost, aiContext, selectedBlastSetup); await _usageLogger.LogAsync(companyId, user?.Id ?? "", AppConstants.AiFeatures.PhotoQuote, result.Success, photos.Sum(p => p.Data.Length)); // Attach historical price benchmark if we got a valid result if (result.Success && !result.NeedsFollowUp) { try { result.Benchmark = await BuildBenchmarkAsync(result.Complexity, result.SurfaceAreaSqFt); } catch (Exception bmEx) { _logger.LogWarning(bmEx, "Failed to build AI benchmark (non-fatal)"); } // Persist the raw AI prediction for future reporting (prediction vs actual) // Best-effort: a DB failure here must not hide the AI result from the user try { var prediction = new AiItemPrediction { CompanyId = companyId, PredictedSurfaceAreaSqFt = result.SurfaceAreaSqFt, PredictedMinutes = result.EstimatedMinutes, PredictedComplexity = result.Complexity, PredictedUnitPrice = result.EstimatedUnitPrice, Confidence = result.Confidence, Reasoning = result.AiReasoning, AiTags = result.Tags.Count > 0 ? string.Join(",", result.Tags) : null, ConversationRounds = (request.ConversationHistory?.Count(t => t.Role == "user") ?? 0) + 1 }; await _unitOfWork.AiItemPredictions.AddAsync(prediction); await _unitOfWork.CompleteAsync(); result.AiPredictionId = prediction.Id; } catch (Exception predEx) { _logger.LogWarning(predEx, "Failed to save AI prediction record (non-fatal) — result still returned to user"); } } return Json(result); } catch (Exception ex) { _logger.LogError(ex, "Error in AiAnalyzeItem"); return Json(new AiAnalyzeItemResult { Success = false, ErrorMessage = "An error occurred during AI analysis. Please try again." }); } } /// /// Recalculates an AI item unit price when the user overrides sq ft, minutes, or complexity. /// Runs the same pricing formula as the initial analysis but server-side, so no rates or /// percentages are ever sent to the client. /// [HttpPost] [ValidateAntiForgeryToken] [EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)] public async Task AiRecalcPrice([FromBody] AiRecalcPriceRequest request) { try { var user = await _userManager.GetUserAsync(User); var companyId = user?.CompanyId ?? 0; var costs = await _pricingService.GetOperatingCostsAsync(companyId); if (costs == null) return Json(new AiRecalcPriceResult { Success = false }); decimal avgPowderCost; try { var powders = await _unitOfWork.InventoryItems.FindAsync(i => i.Category != null && i.Category.ToLower().Contains("powder") && i.UnitCost > 0); avgPowderCost = powders.Any() ? powders.Average(p => p.UnitCost) : 8m; } catch { avgPowderCost = 8m; } var (unitPrice, breakdown) = ComputeAiPricePreview(request, costs, avgPowderCost); return Json(new AiRecalcPriceResult { Success = true, UnitPrice = unitPrice, Breakdown = breakdown }); } catch (Exception ex) { _logger.LogError(ex, "Error in AiRecalcPrice"); return Json(new AiRecalcPriceResult { Success = false }); } } /// /// Pricing formula shared by AiRecalcPrice and (indirectly) the initial AI analysis. /// Accepts per-item inputs and returns a unit price with a display-safe breakdown. /// Preheat and material-floor logic are excluded — only apply on the initial AI pass. /// private static (decimal UnitPrice, AiPricingBreakdown Breakdown) ComputeAiPricePreview( AiRecalcPriceRequest request, CompanyOperatingCosts costs, decimal avgPowderCostPerLb) { const decimal defaultCoverage = 30m; const decimal defaultEfficiency = 0.65m; var lbsPerCoat = request.SurfaceAreaSqFt > 0 ? request.SurfaceAreaSqFt / (defaultCoverage * defaultEfficiency) : 0m; var materialCost = lbsPerCoat * request.CoatCount * avgPowderCostPerLb; var consumablesSurcharge = materialCost * 0.05m; var laborCost = (request.EstimatedMinutes / 60m) * costs.StandardLaborRate; var materialWithMarkup = (materialCost + consumablesSurcharge) * (1 + costs.GeneralMarkupPercentage / 100m); var subtotal = materialWithMarkup + laborCost; var complexityPct = request.Complexity switch { "Simple" => costs.ComplexitySimplePercent / 100m, "Moderate" => costs.ComplexityModeratePercent / 100m, "Complex" => costs.ComplexityComplexPercent / 100m, "Extreme" => costs.ComplexityExtremePercent / 100m, _ => 0m }; var complexityCharge = subtotal * complexityPct; subtotal += complexityCharge; if (subtotal < costs.ShopMinimumCharge && costs.ShopMinimumCharge > 0) subtotal = costs.ShopMinimumCharge; var unitPrice = Math.Max(0, Math.Round(subtotal, 2)); var markupAmount = (materialCost + consumablesSurcharge) * (costs.GeneralMarkupPercentage / 100m); var ovenMinutes = costs.DefaultOvenCycleMinutes > 0 ? costs.DefaultOvenCycleMinutes : 45; var breakdown = new AiPricingBreakdown { SurfaceAreaSqFt = Math.Round(request.SurfaceAreaSqFt, 2), PowderLbsPerCoat = Math.Round(lbsPerCoat, 3), CoatCount = request.CoatCount, MaterialCost = Math.Round(materialCost, 2), ConsumablesCost = Math.Round(consumablesSurcharge, 2), EstimatedMinutes = request.EstimatedMinutes, LaborCost = Math.Round(laborCost, 2), OvenCycleMinutes = ovenMinutes, OvenCost = 0m, SubtotalBeforeComplexity = Math.Round(materialWithMarkup + laborCost, 2), Complexity = request.Complexity, ComplexityCharge = Math.Round(complexityCharge, 2), SubtotalBeforeMarkup = Math.Round(materialWithMarkup + laborCost, 2), MarkupAmount = Math.Round(markupAmount, 2), UnitPrice = unitPrice, }; return (unitPrice, breakdown); } /// /// Builds the per-company AI context passed to the AI quote service on every analysis call. /// Combines the company's free-text AI profile with recent accepted predictions as few-shot examples. /// Both layers are optional — the service degrades gracefully when either is absent. /// private async Task BuildCompanyAiContextAsync(int companyId, CompanyOperatingCosts costs) { try { var context = new CompanyAiContext { ProfileText = costs.AiContextProfile }; // Pull recent accepted predictions (user didn't override) as few-shot calibration examples var allPredictions = await _unitOfWork.AiItemPredictions.FindAsync( p => !p.UserOverrodeEstimate && p.PredictedSurfaceAreaSqFt > 0 && p.PredictedUnitPrice > 0); context.AcceptedExamples = allPredictions .OrderByDescending(p => p.CreatedAt) .Take(8) .Select(p => new AiFewShotExample { Description = p.Reasoning?.Split('.').FirstOrDefault()?.Trim() ?? "Item", SurfaceAreaSqFt = p.PredictedSurfaceAreaSqFt, Complexity = p.PredictedComplexity, EstimatedMinutes = p.PredictedMinutes, FinalUnitPrice = p.PredictedUnitPrice, Tags = p.AiTags }) .ToList(); return context; } catch (Exception ex) { _logger.LogWarning(ex, "Failed to build company AI context (non-fatal) — proceeding without context"); return null; } } /// /// After pricing is determined for an AI item, update the prediction record to flag whether /// the user changed the AI's estimated surface area or unit price before accepting. /// This data powers the "AI accuracy" reporting queries. /// private async Task UpdateAiPredictionOverrideAsync(CreateQuoteItemDto itemDto, decimal finalUnitPrice) { if (!itemDto.AiPredictionId.HasValue) return; var prediction = await _unitOfWork.AiItemPredictions.GetByIdAsync(itemDto.AiPredictionId.Value); if (prediction == null) return; var sqftDiff = Math.Abs(prediction.PredictedSurfaceAreaSqFt - itemDto.SurfaceAreaSqFt); var priceDiff = Math.Abs(prediction.PredictedUnitPrice - finalUnitPrice); prediction.UserOverrodeEstimate = sqftDiff > 0.01m || priceDiff > 0.01m; prediction.UpdatedAt = DateTime.UtcNow; // Change is tracked by EF; will be persisted on the next CompleteAsync() } /// /// Builds a benchmark summary comparing the AI's estimate to historical completed jobs of /// similar complexity and surface area (±60% sqft range). The benchmark is displayed /// alongside the AI result so the user can gauge how reasonable the estimate is. /// Returns null when there is insufficient historical data (fewer than 2 comparable jobs). /// private async Task BuildBenchmarkAsync(string complexity, decimal sqFt) { try { var sqFtMin = sqFt * 0.4m; var sqFtMax = sqFt * 2.5m; var matches = await _unitOfWork.JobItems.FindAsync( ji => ji.Complexity == complexity && ji.SurfaceAreaSqFt >= sqFtMin && ji.SurfaceAreaSqFt <= sqFtMax && ji.UnitPrice > 0 && !ji.IsLaborItem); var jobIds = matches.Select(ji => ji.JobId).Distinct().ToList(); var completedStatusIds = (await _unitOfWork.JobStatusLookups.FindAsync( s => s.StatusCode == "COMPLETED" || s.StatusCode == "DELIVERED")) .Select(s => s.Id).ToHashSet(); var completedJobs = await _unitOfWork.Jobs.FindAsync( j => jobIds.Contains(j.Id) && completedStatusIds.Contains(j.JobStatusId)); var completedJobIds = completedJobs.Select(j => j.Id).ToHashSet(); var completed = matches.Where(ji => completedJobIds.Contains(ji.JobId)).ToList(); if (completed.Count == 0) return null; return new AiBenchmarkResult { MatchCount = completed.Count, MinPrice = completed.Min(ji => ji.UnitPrice), MaxPrice = completed.Max(ji => ji.UnitPrice), AvgPrice = completed.Average(ji => ji.UnitPrice), ComplexityLevel = complexity, SqFtRangeMin = sqFtMin, SqFtRangeMax = sqFtMax }; } catch { return null; // benchmark is optional — never block the analysis } } private static string FormatFileSize(long bytes) { if (bytes < 1024) return $"{bytes} B"; if (bytes < 1024 * 1024) return $"{bytes / 1024.0:F1} KB"; return $"{bytes / (1024.0 * 1024):F1} MB"; } /// /// Formats discount details as a human-readable string for the change history log. /// Returns "None" when no discount is applied, or "X% off (Reason: ...)" / "$X off (Reason: ...)" /// depending on the discount type, so the history entry is self-explanatory. /// private string FormatDiscountForHistory(DiscountType discountType, decimal discountValue, string? discountReason) { if (discountType == DiscountType.None || discountValue == 0) { return "None"; } var discountText = discountType == DiscountType.Percentage ? $"{discountValue:N2}% off" : $"{discountValue:C} off"; if (!string.IsNullOrWhiteSpace(discountReason)) { discountText += $" (Reason: {discountReason})"; } return discountText; } // ─── Quote Photo Actions ──────────────────────────────────────────────── /// Temp upload for Create page (no quoteId yet). [HttpPost] [ValidateAntiForgeryToken] public async Task UploadQuotePhotoTemp(IFormFile? file) { if (file == null || file.Length == 0) return Json(new { success = false, error = "No file provided." }); var user = await _userManager.GetUserAsync(User); var companyId = user?.CompanyId ?? 0; var (success, tempId, _, error) = await _photoService.SaveTempPhotoAsync(file, companyId); if (!success) return Json(new { success = false, error }); return Json(new { success = true, tempId, fileName = file.FileName, fileSize = file.Length, fileSizeDisplay = FormatFileSize(file.Length) }); } /// Direct upload for Edit/Details pages (quoteId known). [HttpPost] [ValidateAntiForgeryToken] public async Task UploadQuotePhoto(int quoteId, IFormFile? file) { if (file == null || file.Length == 0) return Json(new { success = false, error = "No file provided." }); var user = await _userManager.GetUserAsync(User); if (user == null) return Json(new { success = false, error = "Not authenticated." }); var companyId = user.CompanyId; var quote = await _unitOfWork.Quotes.GetByIdAsync(quoteId); if (quote == null || quote.CompanyId != companyId) return Json(new { success = false, error = "Quote not found." }); if (!await _subscriptionService.CanAddQuotePhotoAsync(companyId, quoteId)) { var (used, max) = await _subscriptionService.GetQuotePhotoCountAsync(companyId, quoteId); return Json(new { success = false, error = $"Photo limit reached ({used}/{max}). Upgrade your plan to add more photos." }); } var (success, tempId, _, error) = await _photoService.SaveTempPhotoAsync(file, companyId); if (!success) return Json(new { success = false, error }); var (promoted, photoPath, promoteError) = await _photoService.PromoteTempPhotoAsync(tempId, quoteId, companyId); if (!promoted) return Json(new { success = false, error = promoteError }); var ext = Path.GetExtension(photoPath).ToLowerInvariant(); var ct = ext switch { ".jpg" or ".jpeg" => "image/jpeg", ".png" => "image/png", ".gif" => "image/gif", ".webp" => "image/webp", _ => "application/octet-stream" }; var photoEntity = new QuotePhoto { QuoteId = quoteId, CompanyId = companyId, TempId = tempId, FilePath = photoPath, FileName = file.FileName, FileSize = file.Length, ContentType = ct, IsAiAnalysisPhoto = false, UploadedById = user.Id, CreatedAt = DateTime.UtcNow }; await _unitOfWork.QuotePhotos.AddAsync(photoEntity); await _unitOfWork.CompleteAsync(); return Json(new { success = true, id = photoEntity.Id, fileName = photoEntity.FileName, fileSizeDisplay = FormatFileSize(photoEntity.FileSize), url = Url.Action("Photo", "Quotes", new { id = photoEntity.Id }) }); } /// Returns JSON list of all photos for a quote (AJAX). [HttpGet] public async Task GetQuotePhotos(int quoteId) { var user = await _userManager.GetUserAsync(User); if (user == null) return Json(new { success = false }); var companyId = user.CompanyId; var quote = await _unitOfWork.Quotes.GetByIdAsync(quoteId); if (quote == null || quote.CompanyId != companyId) return Json(new { success = false }); var photos = await _unitOfWork.QuotePhotos.FindAsync(p => p.QuoteId == quoteId); var result = photos.OrderBy(p => p.CreatedAt).Select(p => new { id = p.Id, fileName = p.FileName, fileSizeDisplay = FormatFileSize(p.FileSize), isAiPhoto = p.IsAiAnalysisPhoto, url = Url.Action("Photo", "Quotes", new { id = p.Id }) }); return Json(new { success = true, photos = result }); } /// Deletes a quote photo (non-AI only). [HttpPost] [ValidateAntiForgeryToken] public async Task DeleteQuotePhoto(int id) { var user = await _userManager.GetUserAsync(User); if (user == null) return Json(new { success = false, error = "Not authenticated." }); var photo = await _unitOfWork.QuotePhotos.GetByIdAsync(id); if (photo == null || photo.CompanyId != user.CompanyId) return Json(new { success = false, error = "Photo not found." }); if (photo.IsAiAnalysisPhoto) return Json(new { success = false, error = "AI analysis photos cannot be deleted here." }); await _photoService.DeletePhotoAsync(photo.FilePath); await _unitOfWork.QuotePhotos.SoftDeleteAsync(id); await _unitOfWork.CompleteAsync(); return Json(new { success = true }); } private async Task GetCompanyPreferencesAsync(int companyId) { return await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted); } private async Task StampQuoteCreatedAsync(int companyId) { var prefs = await GetCompanyPreferencesAsync(companyId); if (prefs == null || prefs.FirstQuoteCreatedAt.HasValue) return; prefs.FirstQuoteCreatedAt = DateTime.UtcNow; await _unitOfWork.CompleteAsync(); _logger.LogInformation("Recorded first quote creation for company {CompanyId}", companyId); } private async Task StampJobCreatedAsync(int companyId) { var prefs = await GetCompanyPreferencesAsync(companyId); if (prefs == null || prefs.FirstJobCreatedAt.HasValue) return; prefs.FirstJobCreatedAt = DateTime.UtcNow; await _unitOfWork.CompleteAsync(); _logger.LogInformation("Recorded first job creation for company {CompanyId}", companyId); } } // Request model for AJAX pricing calculation // Request model for AJAX status update public class UpdateQuoteStatusRequest { public int QuoteId { get; set; } public int StatusId { get; set; } }