Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/QuotesController.cs
T
spouliot ed35362c7a Add Formula Library ratings, Job Profitability report, and Quote Revision History improvements
- Formula Library ratings: thumbs up/down per company per formula; toggle on/off; sorts by net score; own formulas not rateable; FormulaLibraryRating entity + migration AddFormulaLibraryRatings
- Job Profitability report: actual labor cost (logged hours x StandardLaborRate) vs powder cost vs billed price per job; gross margin % color-coded; time-tracked-only filter; totals footer
- Quote Revision History: track Total price changes on every save; log Sent/Resent events with recipient email; replace flat table with grouped timeline UI (icons per event type, total-change badge on header)
- Setup Wizard: cap CompletedCount at TotalSteps so old 10-step data no longer shows 10/5
- Formula Library card: fix badge overflow on long titles; add Rate: label to make voting buttons discoverable
- Help docs and AI knowledge base updated for all three features

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 09:02:07 -04:00

3979 lines
187 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Text.Json;
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<ApplicationUser> _userManager;
private readonly ILogger<QuotesController> _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 IJobItemAssemblyService _jobItemAssemblyService;
private readonly IQuotePricingAssemblyService _quotePricingAssemblyService;
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;
private readonly ICompanyLogoService _logoService;
private readonly IInventoryAiLookupService _aiLookupService;
public QuotesController(
IUnitOfWork unitOfWork,
IMapper mapper,
IPricingCalculationService pricingService,
UserManager<ApplicationUser> userManager,
ILogger<QuotesController> logger,
IPdfService pdfService,
ITenantContext tenantContext,
IMeasurementConversionService measurementService,
ILookupCacheService lookupCache,
INotificationService notificationService,
ISubscriptionService subscriptionService,
IJobItemAssemblyService jobItemAssemblyService,
IQuotePricingAssemblyService quotePricingAssemblyService,
IConfiguration configuration,
IPlatformSettingsService platformSettings,
IQuotePhotoService photoService,
IAiQuoteService aiService,
IWebHostEnvironment env,
IJobPhotoService jobPhotoService,
IAiUsageLogger usageLogger,
ICompanyLogoService logoService,
IInventoryAiLookupService aiLookupService)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_pricingService = pricingService;
_userManager = userManager;
_logger = logger;
_pdfService = pdfService;
_tenantContext = tenantContext;
_measurementService = measurementService;
_lookupCache = lookupCache;
_notificationService = notificationService;
_subscriptionService = subscriptionService;
_jobItemAssemblyService = jobItemAssemblyService;
_quotePricingAssemblyService = quotePricingAssemblyService;
_configuration = configuration;
_platformSettings = platformSettings;
_photoService = photoService;
_aiService = aiService;
_env = env;
_jobPhotoService = jobPhotoService;
_usageLogger = usageLogger;
_logoService = logoService;
_aiLookupService = aiLookupService;
}
/// <summary>
/// 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 <paramref name="statusCode"/> parameter lets dashboard links deep-link into a specific
/// status bucket by code name (e.g. AppConstants.StatusCodes.Quote.Draft) without knowing the database ID.
/// </summary>
public async Task<IActionResult> Index(
string? searchTerm,
int? statusFilter,
string? statusCode,
string? tagFilter,
string? sortColumn,
string sortDirection = "asc",
int pageNumber = 1,
int pageSize = 25)
{
try
{
// Load statuses once up front — needed for statusCode resolution and default filter
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var quoteStatuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId);
// Resolve statusCode → statusFilter ID if provided (e.g. from dashboard links)
if (!string.IsNullOrWhiteSpace(statusCode) && !statusFilter.HasValue)
{
var match = quoteStatuses.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<Func<Quote, bool>>? 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;
}
else
{
// Default (no filter): hide Converted to keep the list clean.
// Users can view converted quotes by selecting Converted from the status filter.
var convertedId = quoteStatuses
.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Converted)?.Id;
if (convertedId.HasValue)
{
var cId = convertedId.Value;
filter = q => q.QuoteStatusId != cId;
}
}
// Build orderBy function
Func<IQueryable<Quote>, IOrderedQueryable<Quote>> 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<List<QuoteListDto>>(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();
}
var pagedResult = PagedResult<QuoteListDto>.From(
gridRequest, quoteDtos,
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;
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 == AppConstants.StatusCodes.Quote.Draft || s.StatusCode == AppConstants.StatusCodes.Quote.Sent)
.Select(s => s.Id).ToList();
var approvedConvertedIds = quoteStatuses
.Where(s => s.StatusCode == AppConstants.StatusCodes.Quote.Approved || s.StatusCode == AppConstants.StatusCodes.Quote.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 && b.CompanyId == companyId)).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<QuoteListDto>());
}
}
/// <summary>
/// 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).
/// </summary>
public async Task<IActionResult> 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<QuoteDto>(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;
quoteDto.ConvertedToJobNumber = linkedJob.JobNumber;
}
}
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<List<QuoteItemDto>>(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
// OvenCycleMinutes null means "use company default"; resolve it here so the view
// never displays "× 0 min" when the oven was priced against DefaultOvenCycleMinutes.
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 ?? operatingCosts?.DefaultOvenCycleMinutes ?? 0,
FacilityOverheadCost = quote.FacilityOverheadCost,
FacilityOverheadRatePerHour = quote.FacilityOverheadRatePerHour,
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<List<QuoteChangeHistoryDto>>(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.FindAsync(c => c.CompanyId == quote.CompanyId);
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));
}
}
/// <summary>
/// Reassigns a quote to a different customer. Clears any prospect fields so the
/// quote is treated as a real-customer quote after reassignment.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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;
quote.ProspectSmsConsent = false;
quote.ProspectSmsConsentedAt = 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 });
}
/// <summary>
/// Generates and streams the quote PDF.
/// When <paramref name="inline"/> 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 <see cref="BuildQuotePdfAsync"/> which assembles
/// all line items, coats, and pricing data before calling QuestPDF.
/// </summary>
public async Task<IActionResult> 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<QuoteDto>(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;
quoteDto.CustomerNotifyByEmail = customer.NotifyByEmail;
quoteDto.CustomerMobilePhone = customer.MobilePhone;
quoteDto.CustomerNotifyBySms = customer.NotifyBySms;
}
}
var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(id.Value);
quoteDto.QuoteItems = _mapper.Map<List<QuoteItemDto>>(quoteItems);
// Get company info and logo
var currentUser = await _userManager.GetUserAsync(User);
var pdfOperatingCosts = currentUser != null
? await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId)
: null;
// Populate pricing breakdown from stored snapshot values — never recalculate on load
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 ?? pdfOperatingCosts?.DefaultOvenCycleMinutes ?? 0,
FacilityOverheadCost = quote.FacilityOverheadCost,
FacilityOverheadRatePerHour = quote.FacilityOverheadRatePerHour,
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 (logoData, logoContentType) = await LoadCompanyLogoAsync(company);
var pdfBytes = await _pdfService.GenerateQuotePdfAsync(
quoteDto,
logoData,
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 });
}
}
/// <summary>
/// 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 <paramref name="customerId"/> is provided (e.g. when
/// navigating from the Customer Details page using the "New Quote" shortcut).
/// </summary>
public async Task<IActionResult> 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<string>();
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));
}
}
/// <summary>
/// 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 <see cref="IPricingCalculationService"/> 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.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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<string>();
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 == AppConstants.StatusCodes.Quote.Sent);
var draftStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Draft);
// Create quote entity
var quote = _mapper.Map<Quote>(dto);
quote.QuoteNumber = await GenerateQuoteNumberAsync();
quote.PreparedById = currentUser.Id;
quote.CompanyId = currentUser.CompanyId;
if (dto.ProspectSmsConsent)
quote.ProspectSmsConsentedAt = DateTime.UtcNow;
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
_quotePricingAssemblyService.ApplyPricingSnapshot(quote, pricingResult);
// Add quote
await _unitOfWork.Quotes.AddAsync(quote);
await _unitOfWork.CompleteAsync();
// Create quote items with calculated pricing
var itemResults = await _quotePricingAssemblyService.CreateQuoteItemsAsync(
dto.QuoteItems,
quote.Id,
currentUser.CompanyId,
ovenRateOverride,
DateTime.UtcNow);
foreach (var item in itemResults)
{
await _unitOfWork.QuoteItems.AddAsync(item);
}
await _unitOfWork.CompleteAsync();
// Promote AI temp photos to permanent storage and create QuotePhoto records
_logger.LogInformation("CREATE AI photo promotion: AiPhotoTempIds count={Count}, raw values=[{Values}]",
dto.AiPhotoTempIds?.Count ?? 0,
dto.AiPhotoTempIds == null ? "" : string.Join(",", dto.AiPhotoTempIds));
if (dto.AiPhotoTempIds?.Count > 0)
{
foreach (var rawTempId in dto.AiPhotoTempIds.Where(t => !string.IsNullOrWhiteSpace(t)))
{
if (!Guid.TryParse(rawTempId, out var tempGuid))
{
_logger.LogWarning("CREATE AI photo: Guid.TryParse failed for rawTempId={RawTempId}", rawTempId);
continue;
}
var tempId = tempGuid.ToString("N");
_logger.LogInformation("CREATE AI photo: promoting tempId={TempId} for quoteId={QuoteId}", tempId, quote.Id);
var (promoted, photoPath, promoteError) = await _photoService.PromoteTempPhotoAsync(tempId, quote.Id, currentUser.CompanyId);
_logger.LogInformation("CREATE AI photo: 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 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);
}
}
/// <summary>
/// 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.
/// </summary>
public async Task<IActionResult> 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<UpdateQuoteDto>(quote);
dto.QuoteItems = _mapper.Map<List<CreateQuoteItemDto>>(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 ?? "";
ViewBag.CustomerNotifyBySms = quote.Customer.NotifyBySms;
ViewBag.CustomerMobilePhone = quote.Customer.MobilePhone ?? "";
}
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
/// <summary>
/// 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
/// <see cref="QuoteChangeHistory"/> 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.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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,
Total = quote.Total,
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);
// Manage SMS consent timestamp: stamp when first consented, clear when revoked
if (dto.ProspectSmsConsent && !quote.ProspectSmsConsentedAt.HasValue)
quote.ProspectSmsConsentedAt = DateTime.UtcNow;
else if (!dto.ProspectSmsConsent)
quote.ProspectSmsConsentedAt = null;
// Set calculated pricing — snapshot at save time; never recalculate on load
_quotePricingAssemblyService.ApplyPricingSnapshot(quote, pricingResult);
// All change history records are accumulated here, then saved in bulk below
var changeHistories = new List<QuoteChangeHistory>();
// Log a total-change entry now that the new Total is known
if (Math.Round(oldValues.Total, 2) != Math.Round(quote.Total, 2))
{
changeHistories.Add(new QuoteChangeHistory
{
QuoteId = quote.Id,
ChangedByUserId = currentUser.Id,
ChangedAt = DateTime.UtcNow,
FieldName = "Total",
OldValue = oldValues.Total.ToString("C"),
NewValue = quote.Total.ToString("C"),
ChangeDescription = $"Total changed from {oldValues.Total:C} to {quote.Total:C}",
CompanyId = currentUser.CompanyId
});
}
// Track changes
_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)>();
var assembledItems = await _quotePricingAssemblyService.CreateQuoteItemsAsync(
dto.QuoteItems,
quote.Id,
currentUser.CompanyId,
ovenRateOverride,
DateTime.UtcNow);
foreach (var item in assembledItems)
{
_logger.LogInformation("Creating item: {Desc}, Sandblasting={Sand}, Masking={Mask}",
item.Description, item.RequiresSandblasting, item.RequiresMasking);
await _unitOfWork.QuoteItems.AddAsync(item);
if (item.Coats?.Any() == true)
{
_logger.LogInformation("Added {CoatCount} coats to item {Description}", item.Coats.Count, item.Description);
}
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<string>();
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
_logger.LogInformation("EDIT AI photo promotion: AiPhotoTempIds count={Count}, raw values=[{Values}]",
dto.AiPhotoTempIds?.Count ?? 0,
dto.AiPhotoTempIds == null ? "" : string.Join(",", dto.AiPhotoTempIds));
if (dto.AiPhotoTempIds?.Count > 0)
{
foreach (var rawTempId in dto.AiPhotoTempIds.Where(t => !string.IsNullOrWhiteSpace(t)))
{
if (!Guid.TryParse(rawTempId, out var tempGuid))
{
_logger.LogWarning("EDIT AI photo: Guid.TryParse failed for rawTempId={RawTempId}", rawTempId);
continue;
}
var tempId = tempGuid.ToString("N");
_logger.LogInformation("EDIT AI photo: promoting tempId={TempId} for quoteId={QuoteId}", tempId, quote.Id);
var (promoted, photoPath, promoteError) = await _photoService.PromoteTempPhotoAsync(tempId, quote.Id, currentUser.CompanyId);
_logger.LogInformation("EDIT AI photo: 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 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);
}
}
/// <summary>
/// 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).
/// </summary>
public async Task<IActionResult> 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 != AppConstants.StatusCodes.Quote.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<ConvertQuoteToCustomerDto>(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 });
}
}
/// <summary>
/// Converts an approved prospect quote into a real customer record.
/// Creates a new <see cref="Customer"/> 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.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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<Customer>(dto);
customer.CompanyId = currentUser!.CompanyId;
// Carry over SMS consent if staff confirmed it on this form (TCPA compliance)
if (dto.SmsConsent)
{
customer.NotifyBySms = true;
customer.SmsConsentedAt = dto.ProspectSmsConsentedAt ?? DateTime.UtcNow;
customer.SmsConsentMethod = "verbal";
}
await _unitOfWork.Customers.AddAsync(customer);
await _unitOfWork.CompleteAsync();
// Send the TCPA-compliant welcome/opt-in confirmation SMS when consent was granted
if (dto.SmsConsent)
await _notificationService.NotifySmsConsentGrantedAsync(customer);
// Get "Converted" status (cached)
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var statuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId);
var convertedStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.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;
quote.ProspectSmsConsent = false;
quote.ProspectSmsConsentedAt = 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);
}
}
/// <summary>
/// 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 <see cref="CreateJobFromQuote"/> if not reloaded here.
/// See also <see cref="CreateJobFromQuote"/> for the detailed copy logic and AiPredictionId transfer.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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;
quote.ProspectSmsConsent = false;
quote.ProspectSmsConsentedAt = 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 });
}
}
/// <summary>
/// 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
/// <see cref="DeleteConfirmed"/>.
/// </summary>
public async Task<IActionResult> 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<QuoteDto>(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<List<QuoteItemDto>>(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));
}
}
/// <summary>
/// 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.
/// </summary>
[HttpPost, ActionName("Delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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));
}
}
/// <summary>
/// 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
/// (<c>QuoteApprovalController</c>) which uses a signed token link sent via email.
/// After approval, the Details page surfaces a "Convert to Job" button.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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 == AppConstants.StatusCodes.Quote.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 == AppConstants.StatusCodes.Quote.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);
// Create incoming inventory records for any catalog-sourced coats that were deferred
// from quote-save time. One record per unique powder catalog item, de-duplicated.
try
{
await _quotePricingAssemblyService.EnsureIncomingInventoryForApprovedQuoteAsync(id, quote.CompanyId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "EnsureIncomingInventory failed for quote {QuoteId} — continuing", 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 });
}
}
/// <summary>
/// AJAX endpoint that calculates the full pricing breakdown for the quote wizard in real time.
/// Called by <c>item-wizard.js</c> 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.
/// </summary>
[HttpPost]
public async Task<IActionResult> 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
/// <summary>
/// Loads company email notification preferences and stores defaults in ViewBag.
/// ViewBag.EmailDefaultOnApprove — default checked state for quote-approved emails
/// </summary>
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);
}
/// <summary>
/// Populates all ViewBag data needed to render the Create/Edit quote wizard dropdowns and feature flags.
/// The <paramref name="fallbackOvenRate"/> 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.
/// </summary>
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
var allowFormulas = HttpContext.Items["AllowCustomFormulas"] as bool? ?? false;
if (allowFormulas)
{
var formulaTemplates = await _unitOfWork.CustomItemTemplates.FindAsync(
t => t.CompanyId == companyId && t.IsActive);
ViewBag.CustomFormulaTemplates = formulaTemplates
.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name)
.Select(t => new
{
id = t.Id,
name = t.Name,
description = t.Description,
outputMode = t.OutputMode,
fieldsJson = t.FieldsJson,
formula = t.Formula,
defaultRate = t.DefaultRate,
rateLabel = t.RateLabel,
diagramImagePath = string.IsNullOrEmpty(t.DiagramImagePath)
? (string?)null
: Url.Action("TemplateDiagram", "CompanySettings", new { templateId = t.Id })
}).ToList();
}
else
{
ViewBag.CustomFormulaTemplates = new List<object>();
}
// Customers
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
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();
// Customers with no email address on file — JS hides the email section entirely
ViewBag.CustomerNoEmailIds = customers
.Where(c => string.IsNullOrWhiteSpace(c.Email))
.Select(c => c.Id)
.ToHashSet();
// Customers who have given SMS consent with a mobile number on file
ViewBag.CustomerSmsConsentIds = customers
.Where(c => c.NotifyBySms && !string.IsNullOrWhiteSpace(c.MobilePhone))
.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 — include incoming items so they can be quoted while powder is in transit
var inventory = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId, false, i => i.InventoryCategory);
ViewBag.InventoryCoatings = inventory
.Where(i => i.IsActive && i.InventoryCategory?.IsActive == true && i.InventoryCategory.IsCoating)
.OrderBy(i => i.IsIncoming ? 1 : 0).ThenBy(i => i.InventoryCategory!.DisplayOrder).ThenBy(i => i.ColorName ?? i.Name)
.Select(i => new
{
value = i.Id.ToString(),
text = i.IsIncoming
? $"[INCOMING] {i.InventoryCategory!.DisplayName} - {i.Manufacturer ?? "Generic"} - {i.ColorName ?? i.Name} - {i.ColorCode ?? "N/A"} ({i.UnitCost:C4}/unit)"
: $"{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 ?? "",
isIncoming = i.IsIncoming
}).ToList();
// Vendors
var vendors = await _unitOfWork.Vendors.FindAsync(s => s.CompanyId == companyId, 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.FindAsync(i => i.CompanyId == companyId, 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 && ps.CompanyId == companyId);
ViewBag.PrepServices = prepServices.OrderBy(ps => ps.DisplayOrder).ToList();
// Blast setups for wizard dropdown
var blastSetups = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive && b.CompanyId == companyId);
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<SelectListItem>
{
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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
private async Task PopulatePricingTiersDropDownAsync()
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var pricingTiers = await _unitOfWork.PricingTiers.FindAsync(pt => pt.CompanyId == companyId);
ViewBag.PricingTiers = pricingTiers.OrderBy(pt => pt.TierName)
.Select(pt => new SelectListItem
{
Value = pt.Id.ToString(),
Text = $"{pt.TierName} ({pt.DiscountPercent}% discount)"
}).ToList();
}
/// <summary>
/// Builds the full hierarchical display label for a catalog item in the wizard dropdown.
/// Traverses the category tree bottom-up to produce "Category &gt; Subcategory &gt; 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.
/// </summary>
private string BuildCatalogItemDisplayText(CatalogItem item)
{
var categoryPath = new List<string>();
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}";
}
/// <summary>
/// Generates the raw PDF bytes for a quote using QuestPDF. Shared by both the
/// <see cref="DownloadPdf"/> 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 <see cref="IPdfService"/>.
/// </summary>
private async Task<byte[]> 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<QuoteDto>(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;
quoteDto.CustomerNotifyByEmail = customer.NotifyByEmail;
quoteDto.CustomerMobilePhone = customer.MobilePhone;
quoteDto.CustomerNotifyBySms = customer.NotifyBySms;
}
}
var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(quoteId);
quoteDto.QuoteItems = _mapper.Map<List<QuoteItemDto>>(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
};
var (logoData, logoContentType) = await LoadCompanyLogoAsync(company);
return await _pdfService.GenerateQuotePdfAsync(
quoteDto,
logoData,
logoContentType,
companyInfo,
template: template);
}
/// <summary>
/// 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.
/// </summary>
private async Task<string> 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}";
}
/// <summary>
/// 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 <see cref="CreateJobFromQuote"/> so that
/// a single click can approve and create the job simultaneously (the auto-approve workflow).
/// Records a <see cref="QuoteChangeHistory"/> entry for every status change.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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 == AppConstants.StatusCodes.Quote.Approved && oldStatusCode != AppConstants.StatusCodes.Quote.Approved)
{
quote.ApprovedDate = DateTime.UtcNow;
}
await _unitOfWork.Quotes.UpdateAsync(quote);
await _unitOfWork.SaveChangesAsync();
// When transitioning to Approved: create incoming inventory records for catalog-sourced
// coats that were deferred from quote-save time (one record per unique powder, deduplicated).
if (newStatus.StatusCode == AppConstants.StatusCodes.Quote.Approved && oldStatusCode != AppConstants.StatusCodes.Quote.Approved)
{
try
{
await _quotePricingAssemblyService.EnsureIncomingInventoryForApprovedQuoteAsync(request.QuoteId, quote.CompanyId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "EnsureIncomingInventory failed for quote {QuoteId} — continuing", request.QuoteId);
}
}
// 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 == AppConstants.StatusCodes.Quote.Approved && oldStatusCode != AppConstants.StatusCodes.Quote.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" });
}
}
/// <summary>
/// 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").
/// </summary>
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 — scope to quote's company for defense-in-depth
var jobStatuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == quote.CompanyId);
var jobPriorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == quote.CompanyId);
var approvedStatus = jobStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.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
OvenBatches = quote.OvenBatches > 0 ? quote.OvenBatches : 1,
OvenCycleMinutes = quote.OvenCycleMinutes,
Description = quote.Description ?? $"Job from Quote {quote.QuoteNumber}",
JobStatusId = approvedStatus?.Id ?? 1,
JobPriorityId = selectedPriority?.Id ?? 1,
QuotedPrice = quote.Total,
FinalPrice = quote.Total,
OvenBatchCost = quote.OvenBatchCost,
ShopSuppliesAmount = quote.ShopSuppliesAmount,
ShopSuppliesPercent = quote.ShopSuppliesPercent,
PricingBreakdownJson = JsonSerializer.Serialize(new QuotePricingBreakdownDto
{
MaterialCosts = quote.MaterialCosts,
LaborCosts = quote.LaborCosts,
EquipmentCosts = quote.EquipmentCosts,
ItemsSubtotal = quote.ItemsSubtotal,
OvenBatchCost = quote.OvenBatchCost,
OvenBatches = quote.OvenBatches,
OvenCycleMinutes = quote.OvenCycleMinutes ?? 0,
FacilityOverheadCost = quote.FacilityOverheadCost,
FacilityOverheadRatePerHour = quote.FacilityOverheadRatePerHour,
ShopSuppliesAmount = quote.ShopSuppliesAmount,
ShopSuppliesPercent = quote.ShopSuppliesPercent,
OverheadCosts = quote.OverheadAmount,
OverheadPercent = quote.OverheadPercent,
ProfitMargin = quote.ProfitMargin,
ProfitPercent = quote.ProfitPercent,
SubtotalBeforeDiscount = quote.SubTotal,
PricingTierDiscountAmount = quote.PricingTierDiscountAmount,
PricingTierDiscountPercent = quote.PricingTierDiscountPercent,
QuoteDiscountAmount = quote.QuoteDiscountAmount,
QuoteDiscountPercent = quote.QuoteDiscountPercent,
DiscountAmount = quote.DiscountAmount,
DiscountPercent = quote.DiscountPercent,
SubtotalAfterDiscount = quote.SubtotalAfterDiscount,
RushFee = quote.RushFee,
TaxAmount = quote.TaxAmount,
TaxPercent = quote.TaxPercent,
Total = 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))
{
var createdAtUtc = DateTime.UtcNow;
var jobItem = _jobItemAssemblyService.CreateJobItem(quoteItem, job.Id, quote.CompanyId, createdAtUtc);
await _unitOfWork.JobItems.AddAsync(jobItem);
await _unitOfWork.SaveChangesAsync(); // Save JobItem first to get its ID
foreach (var coat in _jobItemAssemblyService.CreateJobItemCoats(quoteItem, jobItem.Id, quote.CompanyId, createdAtUtc))
{
await _unitOfWork.JobItemCoats.AddAsync(coat);
_logger.LogInformation("Created JobItemCoat '{CoatName}' (Sequence {Seq}) - Color: {Color} ({Code})",
coat.CoatName, coat.Sequence, coat.ColorName ?? "N/A", coat.ColorCode ?? "N/A");
}
foreach (var prepService in _jobItemAssemblyService.CreateJobItemPrepServices(quoteItem, jobItem.Id, quote.CompanyId, createdAtUtc))
{
await _unitOfWork.JobItemPrepServices.AddAsync(prepService);
}
}
await _unitOfWork.SaveChangesAsync();
// Aggregate unique prep services from the fully-loaded quote items and copy to job
var uniquePrepServiceIds = fullItems
.SelectMany(qi => qi.PrepServices)
.Where(ps => !ps.IsDeleted)
.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 and mark it as converted
quote.ConvertedToJobId = job.Id;
quote.ConvertedDate = DateTime.UtcNow;
var companyIdForStatus = quote.CompanyId;
var quoteStatuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyIdForStatus);
var convertedQuoteStatus = quoteStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Converted);
if (convertedQuoteStatus != null)
quote.QuoteStatusId = convertedQuoteStatus.Id;
await _unitOfWork.SaveChangesAsync();
// The interceptor just bumped quote.UpdatedAt as part of the ConvertedToJobId write.
// Advance the job's snapshot past that update — otherwise the comparison
// job.Quote.UpdatedAt > job.QuoteSnapshotUpdatedAt is immediately true and the
// "source quote was modified" banner fires on every newly-converted job.
job.QuoteSnapshotUpdatedAt = quote.UpdatedAt ?? quote.CreatedAt;
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);
}
/// <summary>
/// Generates the next sequential job number in the format PREFIX-YYMM-#### (e.g. JOB-2404-0001).
/// Mirrors <see cref="GenerateQuoteNumberAsync"/> but uses the JobNumberPrefix preference.
/// Called from <see cref="CreateJobFromQuote"/> so the job number is assigned at conversion time.
/// </summary>
private async Task<string> 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}";
}
/// <summary>
/// AJAX endpoint that emails the quote PDF to the customer (or prospect).
/// Attaches the PDF generated by <see cref="BuildQuotePdfAsync"/> and records the
/// notification in <see cref="NotificationLog"/> 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).
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ResendQuote(int id, string? overrideEmail = null)
{
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." });
var trimmedOverride = overrideEmail?.Trim();
// Determine recipient for feedback message
string? recipientEmail = !string.IsNullOrWhiteSpace(trimmedOverride)
? trimmedOverride
: (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;
// Advance from Draft → Sent (mirrors the Create and SendSms paths)
var resendCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var resendStatuses = await _lookupCache.GetQuoteStatusLookupsAsync(resendCompanyId);
var resendSentStatus = resendStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Sent);
var resendDraftStatus = resendStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Draft);
if (resendSentStatus != null && quote.QuoteStatusId == (resendDraftStatus?.Id ?? 0))
{
quote.QuoteStatusId = resendSentStatus.Id;
quote.SentDate ??= DateTime.UtcNow;
}
await _unitOfWork.Quotes.UpdateAsync(quote);
// Log send event so the history timeline shows when the quote was emailed
var sentHistoryEntry = new QuoteChangeHistory
{
QuoteId = quote.Id,
ChangedByUserId = currentUser!.Id,
ChangedAt = DateTime.UtcNow,
FieldName = "Sent",
OldValue = null,
NewValue = recipientEmail,
ChangeDescription = $"Quote sent to {recipientName} ({recipientEmail})",
CompanyId = quote.CompanyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.QuoteChangeHistories.AddAsync(sentHistoryEntry);
await _unitOfWork.CompleteAsync();
await _notificationService.NotifyQuoteSentAsync(quote, pdfBytes, pdfFilename, trimmedOverride);
// 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." });
}
}
/// <summary>
/// Sends the quote approval link to the customer via SMS.
/// Reuses the existing approval token when valid; generates a new one only when none exists or it is expired.
/// Does NOT regenerate a live token — so a previously emailed link stays valid.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SendQuoteApprovalSms(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 phone for the feedback message
string? recipientPhone = quote.CustomerId.HasValue
? (quote.Customer?.MobilePhone ?? quote.Customer?.Phone)
: quote.ProspectPhone;
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");
// Ensure a valid (non-expired) approval token exists — generate only if missing or expired
bool tokenChanged = false;
if (string.IsNullOrEmpty(quote.ApprovalToken) ||
(quote.ApprovalTokenExpiresAt.HasValue && quote.ApprovalTokenExpiresAt.Value < DateTime.UtcNow))
{
var tokenBytes = System.Security.Cryptography.RandomNumberGenerator.GetBytes(32);
quote.ApprovalToken = Convert.ToBase64String(tokenBytes)
.Replace('+', '-').Replace('/', '_').TrimEnd('=');
quote.ApprovalTokenExpiresAt = DateTime.UtcNow.AddDays(
int.TryParse(await _platformSettings.GetAsync(PlatformSettingKeys.QuoteApprovalTokenDays), out var td) ? td : 30);
quote.ApprovalTokenUsedAt = null;
tokenChanged = true;
}
// Advance quote to Sent status when it is still in Draft — mirrors what the email send path does.
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var statuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId);
var sentStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Sent);
var draftStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Draft);
if (sentStatus != null && quote.QuoteStatusId == (draftStatus?.Id ?? 0))
{
quote.QuoteStatusId = sentStatus.Id;
quote.SentDate ??= DateTime.UtcNow;
tokenChanged = true; // ensure a save happens
}
if (tokenChanged)
{
await _unitOfWork.Quotes.UpdateAsync(quote);
await _unitOfWork.CompleteAsync();
}
var (success, error) = await _notificationService.NotifyQuoteSentSmsAsync(quote);
if (!success)
return Json(new { success = false, message = error ?? "SMS could not be sent." });
var phone = string.IsNullOrWhiteSpace(recipientPhone) ? "their phone" : recipientPhone;
return Json(new { success = true, message = $"Approval link sent to {recipientName} via SMS ({phone})." });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error sending quote approval SMS for quote {QuoteId}", id);
return Json(new { success = false, message = "An unexpected error occurred. Please try again." });
}
}
/// <summary>
/// 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.
/// </summary>
[HttpGet]
public async Task<IActionResult> 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 ────────────────────────────────────────────────
/// <summary>Serve a quote photo by ID (photos are stored outside wwwroot).</summary>
[HttpGet]
public async Task<IActionResult> 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);
}
/// <summary>
/// Uploads a single photo to temporary storage as part of the AI Photo Quote wizard flow.
/// Photos are saved to <c>media/temp/{tempId}/</c> and are NOT yet linked to any quote —
/// they are promoted to a permanent path in the Create POST via <see cref="IQuotePhotoService"/>.
/// 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.
/// </summary>
[HttpPost]
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
public async Task<IActionResult> 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)
});
}
/// <summary>
/// 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 <see cref="AiItemPrediction"/> row for ML benchmarking
/// and accuracy reporting. Injects company AI profile context via <see cref="BuildCompanyAiContextAsync"/>
/// so the model's estimates are calibrated to this company's pricing and material usage patterns.
/// </summary>
[HttpPost]
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
public async Task<IActionResult> 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 && b.CompanyId == companyId);
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." });
}
}
/// <summary>
/// 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.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
public async Task<IActionResult> 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 });
}
}
/// <summary>
/// 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.
/// </summary>
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;
// Additional coat charge — each coat beyond the first adds AdditionalCoatLaborPercent%,
// matching the formula in PricingCalculationService.CalculateQuoteItemPriceAsync.
if (request.CoatCount > 1)
subtotal += subtotal * (request.CoatCount - 1) * (costs.AdditionalCoatLaborPercent / 100m);
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);
}
/// <summary>
/// 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.
/// </summary>
private async Task<CompanyAiContext?> 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;
}
}
/// <summary>
/// Creates a 0-balance IsIncoming inventory item from a powder catalog entry so that
/// QR codes can be printed on work orders while the powder is still in transit.
/// Returns the new inventory item ID, or null if creation fails (non-fatal — the coat
/// falls back to custom-powder pricing without an inventory link).
/// </summary>
/// <summary>
/// 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).
/// </summary>
private async Task<AiBenchmarkResult?> 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 == AppConstants.StatusCodes.Job.Completed || s.StatusCode == AppConstants.StatusCodes.Job.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";
}
/// <summary>
/// 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.
/// </summary>
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 ────────────────────────────────────────────────
/// <summary>Temp upload for Create page (no quoteId yet).</summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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)
});
}
/// <summary>Direct upload for Edit/Details pages (quoteId known).</summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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 })
});
}
/// <summary>Returns JSON list of all photos for a quote (AJAX).</summary>
[HttpGet]
public async Task<IActionResult> 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 });
}
/// <summary>Deletes a quote photo (non-AI only).</summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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 });
}
/// <summary>Updates the caption of a non-AI quote photo.</summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UpdateQuotePhoto(int id, string? caption)
{
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 edited." });
photo.Caption = string.IsNullOrWhiteSpace(caption) ? null : caption.Trim();
photo.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.QuotePhotos.UpdateAsync(photo);
await _unitOfWork.CompleteAsync();
return Json(new { success = true });
}
private async Task<CompanyPreferences?> 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);
}
/// <summary>
/// Returns logo bytes and content type for PDF generation.
/// Prefers blob-stored logos (LogoFilePath) over the legacy DB column (LogoData)
/// so that companies that uploaded a new logo after initial setup see it in PDFs.
/// </summary>
private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company company)
{
if (!string.IsNullOrEmpty(company.LogoFilePath))
{
var (ok, content, contentType, _) = await _logoService.GetCompanyLogoAsync(company.LogoFilePath);
if (ok) return (content, contentType);
}
return (company.LogoData, company.LogoContentType);
}
/// <summary>
/// Inline-edits description, quantity, and unit price on a single quote line item.
/// Adjusts stored quote totals by the price delta so the sidebar stays accurate.
/// Returns updated totals so the page can reflect the change without a reload.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> PatchItem([FromBody] PatchQuoteItemRequest request)
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Unauthorized();
var item = await _unitOfWork.QuoteItems.GetByIdAsync(request.ItemId);
if (item == null) return NotFound();
var quote = await _unitOfWork.Quotes.GetByIdAsync(item.QuoteId);
if (quote == null || quote.CompanyId != currentUser.CompanyId) return NotFound();
var oldTotal = item.TotalPrice;
item.Description = request.Description.Trim();
item.Quantity = request.Quantity;
item.UnitPrice = request.UnitPrice;
item.TotalPrice = Math.Round(request.Quantity * request.UnitPrice, 2);
await _unitOfWork.QuoteItems.UpdateAsync(item);
// Cascade delta through stored totals without re-running the pricing engine
var delta = item.TotalPrice - oldTotal;
quote.ItemsSubtotal += delta;
quote.SubTotal += delta;
quote.SubtotalAfterDiscount = quote.SubTotal - quote.DiscountAmount;
quote.TaxAmount = Math.Round(quote.SubtotalAfterDiscount * quote.TaxPercent / 100m, 2);
quote.Total = Math.Round(quote.SubtotalAfterDiscount + quote.RushFee + quote.TaxAmount, 2);
await _unitOfWork.Quotes.UpdateAsync(quote);
await _unitOfWork.CompleteAsync();
return Json(new {
lineTotal = item.TotalPrice,
subtotal = quote.SubTotal,
taxAmount = quote.TaxAmount,
total = quote.Total
});
}
}
// 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; }
}
public class PatchQuoteItemRequest
{
public int ItemId { get; set; }
public string Description { get; set; } = string.Empty;
public decimal Quantity { get; set; }
public decimal UnitPrice { get; set; }
}