Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/QuotesController.cs
T
spouliot 9221fcc783 Add quote-changed banner with re-sync to job details
When a source quote is edited after a job was created from it, the job
details page now shows a warning banner with the date of the change and
a link to the quote. Two actions are offered:

- Re-sync from Quote: replaces all job items, coats, prep services, and
  pricing from the current quote. Only available while the job is still
  in a pre-production status (Pending, Quoted, Approved); hidden once
  shop work has started (InPreparation or beyond).
- Dismiss: acknowledges the change without altering the job, clearing
  the banner by advancing the stored snapshot timestamp.

Implemented via Job.QuoteSnapshotUpdatedAt (new nullable column), set at
quote→job conversion time. The banner fires when quote.UpdatedAt exceeds
this baseline. Migration: AddJobQuoteSnapshotUpdatedAt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 18:02:46 -04:00

3884 lines
182 KiB
C#

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 IConfiguration _configuration;
private readonly IPlatformSettingsService _platformSettings;
private readonly IQuotePhotoService _photoService;
private readonly IAiQuoteService _aiService;
private readonly IWebHostEnvironment _env;
private readonly IJobPhotoService _jobPhotoService;
private readonly IAiUsageLogger _usageLogger;
public QuotesController(
IUnitOfWork unitOfWork,
IMapper mapper,
IPricingCalculationService pricingService,
UserManager<ApplicationUser> userManager,
ILogger<QuotesController> logger,
IPdfService pdfService,
ITenantContext tenantContext,
IMeasurementConversionService measurementService,
ILookupCacheService lookupCache,
INotificationService notificationService,
ISubscriptionService subscriptionService,
IConfiguration configuration,
IPlatformSettingsService platformSettings,
IQuotePhotoService photoService,
IAiQuoteService aiService,
IWebHostEnvironment env,
IJobPhotoService jobPhotoService,
IAiUsageLogger usageLogger)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_pricingService = pricingService;
_userManager = userManager;
_logger = logger;
_pdfService = pdfService;
_tenantContext = tenantContext;
_measurementService = measurementService;
_lookupCache = lookupCache;
_notificationService = notificationService;
_subscriptionService = subscriptionService;
_configuration = configuration;
_platformSettings = platformSettings;
_photoService = photoService;
_aiService = aiService;
_env = env;
_jobPhotoService = jobPhotoService;
_usageLogger = usageLogger;
}
/// <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. "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
{
// Resolve statusCode → statusFilter ID if provided (e.g. from dashboard links)
if (!string.IsNullOrWhiteSpace(statusCode) && !statusFilter.HasValue)
{
var companyIdForLookup = _tenantContext.GetCurrentCompanyId() ?? 0;
var allStatuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyIdForLookup);
var match = allStatuses.FirstOrDefault(s =>
s.StatusCode.Equals(statusCode.Trim().ToUpper(), StringComparison.OrdinalIgnoreCase));
if (match != null)
statusFilter = match.Id;
}
// Create and validate grid request
var gridRequest = new GridRequest
{
PageNumber = pageNumber,
PageSize = pageSize,
SortColumn = sortColumn ?? "CreatedAt",
SortDirection = sortColumn == null ? "desc" : sortDirection, // Default to desc for CreatedAt
SearchTerm = searchTerm
};
gridRequest.Validate();
// Build search and status filter
System.Linq.Expressions.Expression<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;
}
// 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();
}
// Create paged result
var pagedResult = new PagedResult<QuoteListDto>
{
Items = quoteDtos,
PageNumber = gridRequest.PageNumber,
PageSize = gridRequest.PageSize,
TotalCount = string.IsNullOrWhiteSpace(tagFilter) ? totalCount : quoteDtos.Count
};
// Set ViewBag for sorting and filters
ViewBag.SearchTerm = searchTerm;
ViewBag.StatusFilter = statusFilter;
ViewBag.StatusCode = statusCode;
ViewBag.TagFilter = tagFilter;
ViewBag.SortColumn = gridRequest.SortColumn;
ViewBag.SortDirection = gridRequest.SortDirection;
// Use cached quote statuses
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var quoteStatuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId);
ViewBag.QuoteStatuses = quoteStatuses
.OrderBy(s => s.DisplayOrder)
.Select(s => new SelectListItem
{
Value = s.Id.ToString(),
Text = s.DisplayName,
Selected = s.Id == statusFilter
});
// Aggregate stats — computed over ALL quotes (not just current page) so stat
// cards always reflect the full dataset regardless of current page or page size.
var draftSentIds = quoteStatuses
.Where(s => s.StatusCode == "DRAFT" || s.StatusCode == "SENT")
.Select(s => s.Id).ToList();
var approvedConvertedIds = quoteStatuses
.Where(s => s.StatusCode == "APPROVED" || s.StatusCode == "CONVERTED")
.Select(s => s.Id).ToList();
var indexStats = await _unitOfWork.Quotes.GetIndexStatsAsync(draftSentIds, approvedConvertedIds);
ViewBag.StatOpenCount = indexStats.OpenCount;
ViewBag.StatApprovedCount = indexStats.ApprovedConvertedCount;
ViewBag.StatTotalValue = indexStats.TotalValue;
// Calibration nudge — suppress when named blast setups exist OR legacy CFM is set
var costs = (await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == companyId)).FirstOrDefault();
var hasNamedSetups = (await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive)).Any();
ViewBag.QuotingNotCalibrated = costs != null
&& !hasNamedSetups
&& costs.CompressorCfm == 0
&& costs.BlastRateSqFtPerHourOverride == null;
return View(pagedResult);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving quotes");
TempData["Error"] = "An error occurred while loading quotes.";
return View(new PagedResult<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;
}
}
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
if (quote.Total > 0)
{
quoteDto.PricingBreakdown = new QuotePricingBreakdownDto
{
MaterialCosts = quote.MaterialCosts,
LaborCosts = quote.LaborCosts,
EquipmentCosts = quote.EquipmentCosts,
ItemsSubtotal = quote.ItemsSubtotal,
OvenBatchCost = quote.OvenBatchCost,
OvenBatches = quote.OvenBatches,
OvenCycleMinutes = quote.OvenCycleMinutes ?? 0,
ShopSuppliesAmount = quote.ShopSuppliesAmount,
ShopSuppliesPercent = quote.ShopSuppliesPercent,
OverheadCosts = quote.OverheadAmount,
OverheadPercent = quote.OverheadPercent,
ProfitMargin = quote.ProfitMargin,
ProfitPercent = quote.ProfitPercent,
SubtotalBeforeDiscount = quote.SubTotal,
DiscountAmount = quote.DiscountAmount,
DiscountPercent = quote.DiscountPercent,
SubtotalAfterDiscount = quote.SubTotal - quote.DiscountAmount,
RushFee = quote.RushFee,
TaxPercent = quote.TaxPercent,
TaxAmount = quote.TaxAmount,
Total = quote.Total
};
}
// Load change history
var changeHistories = await _unitOfWork.Quotes.GetChangeHistoryAsync(id.Value);
var changeHistoryDtos = _mapper.Map<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.GetAllAsync();
ViewBag.CustomerSelectList = allCustomers
.Where(c => c.IsActive)
.Select(c => new SelectListItem
{
Value = c.Id.ToString(),
Text = !string.IsNullOrWhiteSpace(c.CompanyName)
? c.CompanyName
: $"{c.ContactFirstName} {c.ContactLastName}".Trim()
})
.OrderBy(c => c.Text)
.ToList();
var quotePrefs = await GetCompanyPreferencesAsync(currentUser!.CompanyId);
if (guidedActivation == AppConstants.GuidedActivation.QuoteCreatedStep
&& quotePrefs?.OnboardingPath == AppConstants.GuidedActivation.QuoteFirstPath
&& quotePrefs.FirstWorkflowCompleted == false)
{
ViewBag.GuidedActivationMode = AppConstants.GuidedActivation.QuoteFirstPath;
ViewBag.GuidedActivationCallout = new Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel
{
Show = true,
Title = "This is the quote you would send to your customer.",
Message = "Next, convert it into a job so it moves into your real production workflow.",
ActionText = "Convert to Job"
};
}
return View(quoteDto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving quote details for ID {QuoteId}", id);
TempData["Error"] = "An error occurred while loading the quote details.";
return RedirectToAction(nameof(Index));
}
}
/// <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;
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;
}
}
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);
// Populate pricing breakdown from stored snapshot values — never recalculate on load
if (quote.Total > 0)
{
quoteDto.PricingBreakdown = new QuotePricingBreakdownDto
{
MaterialCosts = quote.MaterialCosts,
LaborCosts = quote.LaborCosts,
EquipmentCosts = quote.EquipmentCosts,
ItemsSubtotal = quote.ItemsSubtotal,
OvenBatchCost = quote.OvenBatchCost,
OvenBatches = quote.OvenBatches,
OvenCycleMinutes = quote.OvenCycleMinutes ?? 0,
ShopSuppliesAmount = quote.ShopSuppliesAmount,
ShopSuppliesPercent = quote.ShopSuppliesPercent,
OverheadCosts = quote.OverheadAmount,
OverheadPercent = quote.OverheadPercent,
ProfitMargin = quote.ProfitMargin,
ProfitPercent = quote.ProfitPercent,
SubtotalBeforeDiscount = quote.SubTotal,
DiscountAmount = quote.DiscountAmount,
DiscountPercent = quote.DiscountPercent,
SubtotalAfterDiscount = quote.SubTotal - quote.DiscountAmount,
RushFee = quote.RushFee,
TaxPercent = quote.TaxPercent,
TaxAmount = quote.TaxAmount,
Total = quote.Total
};
}
if (currentUser?.CompanyId == null)
{
TempData["Error"] = "Company information not found.";
return RedirectToAction(nameof(Details), new { id });
}
var company = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId);
if (company == null)
{
TempData["Error"] = "Company information not found.";
return RedirectToAction(nameof(Details), new { id });
}
var companyInfo = new Application.DTOs.Company.CompanyInfoDto
{
CompanyName = company.CompanyName,
Phone = company.Phone,
Address = company.Address,
City = company.City,
State = company.State,
ZipCode = company.ZipCode,
PrimaryContactEmail = company.PrimaryContactEmail
};
// Load company preferences for PDF template settings
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
p => p.CompanyId == currentUser.CompanyId && !p.IsDeleted, ignoreQueryFilters: true);
var template = new Application.DTOs.Company.QuoteTemplateSettingsDto
{
AccentColor = prefs?.QtAccentColor ?? "#374151",
FooterNote = prefs?.QtFooterNote,
DefaultTerms = prefs?.QtDefaultTerms
};
// Generate PDF
var pdfBytes = await _pdfService.GenerateQuotePdfAsync(
quoteDto,
company.LogoData,
company.LogoContentType,
companyInfo,
template: template
);
// Return PDF file
var fileName = $"Quote-{quote.QuoteNumber}.pdf";
if (inline)
{
// Return PDF for inline viewing (for printing)
Response.Headers.Append("Content-Disposition", $"inline; filename=\"{fileName}\"");
return File(pdfBytes, "application/pdf");
}
else
{
// Return PDF as download
return File(pdfBytes, "application/pdf", fileName);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating PDF for quote {QuoteId}", id);
TempData["Error"] = "An error occurred while generating the PDF. Please try again.";
return RedirectToAction(nameof(Details), new { id });
}
}
/// <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 == "SENT");
var draftStatus = statuses.FirstOrDefault(s => s.StatusCode == "DRAFT");
// Create quote entity
var quote = _mapper.Map<Quote>(dto);
quote.QuoteNumber = await GenerateQuoteNumberAsync();
quote.PreparedById = currentUser.Id;
quote.CompanyId = currentUser.CompanyId;
if (dto.SendEmailToCustomer)
{
quote.QuoteStatusId = sentStatus?.Id ?? draftStatus?.Id ?? 1;
quote.SentDate = DateTime.UtcNow;
}
else
{
quote.QuoteStatusId = draftStatus?.Id ?? sentStatus?.Id ?? 1;
quote.SentDate = null;
}
// Set calculated pricing — snapshot at save time; never recalculate on load
quote.MaterialCosts = pricingResult.MaterialCosts;
quote.LaborCosts = pricingResult.LaborCosts;
quote.EquipmentCosts = pricingResult.EquipmentCosts;
quote.ItemsSubtotal = pricingResult.ItemsSubtotal;
quote.OvenBatchCost = pricingResult.OvenBatchCost;
quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount;
quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent;
quote.OverheadAmount = pricingResult.OverheadCosts;
quote.OverheadPercent = pricingResult.OverheadPercent;
quote.ProfitMargin = pricingResult.ProfitMargin;
quote.ProfitPercent = pricingResult.ProfitPercent;
quote.SubTotal = pricingResult.SubtotalBeforeDiscount;
quote.DiscountPercent = pricingResult.DiscountPercent;
quote.DiscountAmount = pricingResult.DiscountAmount;
quote.RushFee = pricingResult.RushFee;
quote.TaxAmount = pricingResult.TaxAmount;
quote.Total = pricingResult.Total;
// Add quote
await _unitOfWork.Quotes.AddAsync(quote);
await _unitOfWork.CompleteAsync();
// Create quote items with calculated pricing
var itemResults = new List<QuoteItem>();
foreach (var itemDto in dto.QuoteItems)
{
var item = _mapper.Map<QuoteItem>(itemDto);
item.QuoteId = quote.Id;
item.CompanyId = currentUser.CompanyId;
// AI items: use stored price (AI estimate or user override) — skip the pricing engine
if (itemDto.IsAiItem && itemDto.ManualUnitPrice.HasValue && itemDto.ManualUnitPrice.Value > 0)
{
item.UnitPrice = itemDto.ManualUnitPrice.Value;
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
_logger.LogInformation("AI item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
}
// Sales/merchandise items: use the manually entered price directly — no coating calculation
else if (itemDto.IsSalesItem && itemDto.ManualUnitPrice.HasValue)
{
item.UnitPrice = itemDto.ManualUnitPrice.Value;
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
_logger.LogInformation("Sales item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
}
// Catalog items: if they have coats, calculate with coats; otherwise use default price
else if (itemDto.CatalogItemId.HasValue)
{
// If catalog item has coats, calculate the full price with coat costs
if (itemDto.Coats != null && itemDto.Coats.Any())
{
_logger.LogInformation("Calculating catalog item with {CoatCount} coats", itemDto.Coats.Count);
var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, currentUser.CompanyId, ovenRateOverride);
item.UnitPrice = itemPricing.UnitPrice;
item.TotalPrice = itemPricing.TotalPrice;
item.ItemMaterialCost = itemPricing.MaterialCost;
item.ItemLaborCost = itemPricing.LaborCost;
item.ItemEquipmentCost = itemPricing.EquipmentCost;
_logger.LogInformation("Set prices: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
}
else
{
// No coats - use catalog default price
var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(itemDto.CatalogItemId.Value);
if (catalogItem != null)
{
item.UnitPrice = catalogItem.DefaultPrice;
item.TotalPrice = catalogItem.DefaultPrice * itemDto.Quantity;
_logger.LogInformation("Catalog item no coats: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
}
}
}
else
{
// Calculated items use the pricing service
_logger.LogInformation("Calculating custom item with {CoatCount} coats", itemDto.Coats?.Count ?? 0);
var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, currentUser.CompanyId, ovenRateOverride);
item.UnitPrice = itemPricing.UnitPrice;
item.TotalPrice = itemPricing.TotalPrice;
item.ItemMaterialCost = itemPricing.MaterialCost;
item.ItemLaborCost = itemPricing.LaborCost;
item.ItemEquipmentCost = itemPricing.EquipmentCost;
_logger.LogInformation("Set prices: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
}
// Flag whether the user overrode the AI's estimates before accepting
await UpdateAiPredictionOverrideAsync(itemDto, item.UnitPrice);
// Map coats for this item with calculated costs
if (itemDto.Coats != null && itemDto.Coats.Any())
{
item.Coats = new List<QuoteItemCoat>();
for (int coatIndex = 0; coatIndex < itemDto.Coats.Count; coatIndex++)
{
var coatDto = itemDto.Coats[coatIndex];
var coat = _mapper.Map<QuoteItemCoat>(coatDto);
coat.CompanyId = currentUser.CompanyId;
// Calculate and store the coat costs
var coatPricing = await _pricingService.CalculateCoatPriceAsync(
coatDto,
itemDto.SurfaceAreaSqFt,
itemDto.Quantity,
coatIndex,
itemDto.EstimatedMinutes,
currentUser.CompanyId);
coat.CoatMaterialCost = coatPricing.CoatMaterialCost;
coat.CoatLaborCost = coatPricing.CoatLaborCost;
coat.CoatTotalCost = coatPricing.CoatTotalCost;
item.Coats.Add(coat);
}
}
// Map per-item prep services
if (itemDto.PrepServices != null && itemDto.PrepServices.Any())
{
item.PrepServices = new List<QuoteItemPrepService>();
foreach (var psDto in itemDto.PrepServices)
{
var prepService = _mapper.Map<QuoteItemPrepService>(psDto);
prepService.CompanyId = currentUser.CompanyId;
item.PrepServices.Add(prepService);
}
}
itemResults.Add(item);
}
foreach (var item in itemResults)
{
await _unitOfWork.QuoteItems.AddAsync(item);
}
await _unitOfWork.CompleteAsync();
// Promote AI temp photos to permanent storage and create QuotePhoto records
if (dto.AiPhotoTempIds?.Count > 0)
{
foreach (var rawTempId in dto.AiPhotoTempIds.Where(t => !string.IsNullOrWhiteSpace(t)))
{
if (!Guid.TryParse(rawTempId, out var tempGuid)) continue;
var tempId = tempGuid.ToString("N");
var (promoted, photoPath, _) = await _photoService.PromoteTempPhotoAsync(tempId, quote.Id, currentUser.CompanyId);
if (promoted)
{
var ext = Path.GetExtension(photoPath).ToLowerInvariant();
var ct = ext switch { ".jpg" or ".jpeg" => "image/jpeg", ".png" => "image/png", ".gif" => "image/gif", ".webp" => "image/webp", _ => "image/jpeg" };
var photoEntity = new QuotePhoto
{
QuoteId = quote.Id,
TempId = tempId,
FilePath = photoPath,
FileName = Path.GetFileName(photoPath),
FileSize = 0,
ContentType = ct,
IsAiAnalysisPhoto = true,
UploadedById = currentUser.Id,
CompanyId = currentUser.CompanyId
};
await _unitOfWork.QuotePhotos.AddAsync(photoEntity);
}
}
await _unitOfWork.CompleteAsync();
}
// Promote general quote photo temp uploads
_logger.LogInformation("CREATE Photo promotion: QuotePhotoTempIds count={Count}, raw values=[{Values}]",
dto.QuotePhotoTempIds?.Count ?? 0,
dto.QuotePhotoTempIds == null ? "" : string.Join(",", dto.QuotePhotoTempIds));
if (dto.QuotePhotoTempIds?.Count > 0)
{
foreach (var rawTempId in dto.QuotePhotoTempIds.Where(t => !string.IsNullOrWhiteSpace(t)))
{
if (!Guid.TryParse(rawTempId, out var tempGuid))
{
_logger.LogWarning("CREATE Photo: Guid.TryParse failed for rawTempId={RawTempId}", rawTempId);
continue;
}
var tempId = tempGuid.ToString("N");
_logger.LogInformation("CREATE Photo: promoting tempId={TempId} for quoteId={QuoteId}", tempId, quote.Id);
if (!await _subscriptionService.CanAddQuotePhotoAsync(currentUser.CompanyId, quote.Id))
{
_logger.LogWarning("CREATE Photo: CanAddQuotePhotoAsync returned false, breaking");
break;
}
var (promoted, photoPath, promoteError) = await _photoService.PromoteTempPhotoAsync(tempId, quote.Id, currentUser.CompanyId);
_logger.LogInformation("CREATE Photo: PromoteTempPhotoAsync result: promoted={Promoted}, path={Path}, error={Error}", promoted, photoPath, promoteError);
if (promoted)
{
var ext = Path.GetExtension(photoPath).ToLowerInvariant();
var ct = ext switch { ".jpg" or ".jpeg" => "image/jpeg", ".png" => "image/png", ".gif" => "image/gif", ".webp" => "image/webp", _ => "image/jpeg" };
var rawIdx = dto.QuotePhotoTempIds.IndexOf(rawTempId);
var rawFileName = (dto.QuotePhotoFileNames?.Count > rawIdx && rawIdx >= 0) ? dto.QuotePhotoFileNames[rawIdx] : Path.GetFileName(photoPath);
var photoEntity = new QuotePhoto
{
QuoteId = quote.Id,
TempId = tempId,
FilePath = photoPath,
FileName = rawFileName,
FileSize = 0,
ContentType = ct,
IsAiAnalysisPhoto = false,
UploadedById = currentUser.Id,
CompanyId = currentUser.CompanyId
};
await _unitOfWork.QuotePhotos.AddAsync(photoEntity);
_logger.LogInformation("CREATE Photo: QuotePhoto entity added to DB for quoteId={QuoteId}", quote.Id);
}
}
await _unitOfWork.CompleteAsync();
}
// Generate customer approval token (so the link is included in the quote email)
var approvalTokenBytes = System.Security.Cryptography.RandomNumberGenerator.GetBytes(32);
quote.ApprovalToken = Convert.ToBase64String(approvalTokenBytes)
.Replace('+', '-').Replace('/', '_').TrimEnd('=');
quote.ApprovalTokenExpiresAt = DateTime.UtcNow.AddDays(
int.TryParse(await _platformSettings.GetAsync(PlatformSettingKeys.QuoteApprovalTokenDays), out var tokenDays) ? tokenDays : 30);
quote.ApprovalTokenUsedAt = null;
await _unitOfWork.Quotes.UpdateAsync(quote);
await _unitOfWork.CompleteAsync();
// Optionally email the quote to the customer/prospect with the PDF attached
if (dto.SendEmailToCustomer)
{
try
{
// Load Customer nav prop onto the already-token-stamped quote object
// (avoids reloading from DB which could return a stale EF cache entry without the token)
if (quote.Customer == null && quote.CustomerId.HasValue)
quote.Customer = await _unitOfWork.Customers.GetByIdAsync(quote.CustomerId.Value);
var quoteForNotify = quote;
{
byte[]? pdfBytes = null;
string? pdfFilename = null;
try
{
pdfBytes = await BuildQuotePdfAsync(quote.Id, currentUser);
pdfFilename = $"Quote-{quote.QuoteNumber}.pdf";
}
catch (Exception pdfEx)
{
_logger.LogWarning(pdfEx, "PDF generation failed for quote {Id}; sending email without attachment", quote.Id);
}
await _notificationService.NotifyQuoteSentAsync(quoteForNotify, pdfBytes, pdfFilename);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Notification failed for quote {Id}", quote.Id);
}
var quoteCreateNotifLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(quote.Id);
this.SetNotificationResultToast(quoteCreateNotifLog);
}
await StampQuoteCreatedAsync(currentUser.CompanyId);
this.ToastSuccess($"Quote {quote.QuoteNumber} created successfully!");
if (guidedActivation == AppConstants.GuidedActivation.QuoteFirstPath)
{
return RedirectToAction(nameof(Details), new
{
id = quote.Id,
guidedActivation = AppConstants.GuidedActivation.QuoteCreatedStep
});
}
return RedirectToAction(nameof(Details), new { id = quote.Id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating quote");
this.ToastError("An error occurred while creating the quote. Please try again.");
var catchUser = await _userManager.GetUserAsync(User);
var catchCosts = await _pricingService.GetOperatingCostsAsync(catchUser!.CompanyId);
await PopulateDropDownsAsync(catchUser.CompanyId, catchCosts?.OvenOperatingCostPerHour ?? 0);
await SetMeasurementViewBagAsync();
ViewBag.GuidedActivation = guidedActivation;
return View(dto);
}
}
/// <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 ?? "";
}
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,
DiscountType = quote.DiscountType,
DiscountValue = quote.DiscountValue,
DiscountReason = quote.DiscountReason,
ProspectCompanyName = quote.ProspectCompanyName,
ProspectContactName = quote.ProspectContactName,
ProspectEmail = quote.ProspectEmail,
ProspectPhone = quote.ProspectPhone,
ProspectAddress = quote.ProspectAddress
};
// Update quote entity
_mapper.Map(dto, quote);
// Set calculated pricing — snapshot at save time; never recalculate on load
quote.MaterialCosts = pricingResult.MaterialCosts;
quote.LaborCosts = pricingResult.LaborCosts;
quote.EquipmentCosts = pricingResult.EquipmentCosts;
quote.ItemsSubtotal = pricingResult.ItemsSubtotal;
quote.OvenBatchCost = pricingResult.OvenBatchCost;
quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount;
quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent;
quote.OverheadAmount = pricingResult.OverheadCosts;
quote.OverheadPercent = pricingResult.OverheadPercent;
quote.ProfitMargin = pricingResult.ProfitMargin;
quote.ProfitPercent = pricingResult.ProfitPercent;
quote.SubTotal = pricingResult.SubtotalBeforeDiscount;
quote.DiscountPercent = pricingResult.DiscountPercent;
quote.DiscountAmount = pricingResult.DiscountAmount;
quote.RushFee = pricingResult.RushFee;
quote.TaxAmount = pricingResult.TaxAmount;
quote.Total = pricingResult.Total;
// Track changes
var changeHistories = new List<QuoteChangeHistory>();
_logger.LogInformation("=== CHANGE TRACKING DEBUG ===");
_logger.LogInformation("Old Status: {OldStatus}, New Status: {NewStatus}", oldValues.QuoteStatusId, quote.QuoteStatusId);
_logger.LogInformation("Old Date: {OldDate}, New Date: {NewDate}", oldValues.QuoteDate, quote.QuoteDate);
_logger.LogInformation("Old Expiration: {OldExp}, New Expiration: {NewExp}", oldValues.ExpirationDate, quote.ExpirationDate);
_logger.LogInformation("Old Terms: {OldTerms}, New Terms: {NewTerms}", oldValues.Terms, quote.Terms);
_logger.LogInformation("Old Notes: {OldNotes}, New Notes: {NewNotes}", oldValues.Notes, quote.Notes);
_logger.LogInformation("Old Tax: {OldTax}, New Tax: {NewTax}", oldValues.TaxPercent, quote.TaxPercent);
if (oldValues.QuoteStatusId != quote.QuoteStatusId)
{
var oldStatus = await _unitOfWork.QuoteStatusLookups.GetByIdAsync(oldValues.QuoteStatusId);
var newStatus = await _unitOfWork.QuoteStatusLookups.GetByIdAsync(quote.QuoteStatusId);
changeHistories.Add(new QuoteChangeHistory
{
QuoteId = quote.Id,
ChangedByUserId = currentUser.Id,
ChangedAt = DateTime.UtcNow,
FieldName = "Status",
OldValue = oldStatus?.DisplayName ?? oldValues.QuoteStatusId.ToString(),
NewValue = newStatus?.DisplayName ?? quote.QuoteStatusId.ToString(),
ChangeDescription = $"Status changed from {oldStatus?.DisplayName} to {newStatus?.DisplayName}",
CompanyId = currentUser.CompanyId
});
}
if (oldValues.QuoteDate != quote.QuoteDate)
{
changeHistories.Add(new QuoteChangeHistory
{
QuoteId = quote.Id,
ChangedByUserId = currentUser.Id,
ChangedAt = DateTime.UtcNow,
FieldName = "Quote Date",
OldValue = oldValues.QuoteDate.ToString("MM/dd/yyyy"),
NewValue = quote.QuoteDate.ToString("MM/dd/yyyy"),
ChangeDescription = $"Quote date changed from {oldValues.QuoteDate:MM/dd/yyyy} to {quote.QuoteDate:MM/dd/yyyy}",
CompanyId = currentUser.CompanyId
});
}
if (oldValues.ExpirationDate != quote.ExpirationDate)
{
changeHistories.Add(new QuoteChangeHistory
{
QuoteId = quote.Id,
ChangedByUserId = currentUser.Id,
ChangedAt = DateTime.UtcNow,
FieldName = "Expiration Date",
OldValue = oldValues.ExpirationDate?.ToString("MM/dd/yyyy") ?? "None",
NewValue = quote.ExpirationDate?.ToString("MM/dd/yyyy") ?? "None",
ChangeDescription = $"Expiration date changed from {oldValues.ExpirationDate?.ToString("MM/dd/yyyy") ?? "None"} to {quote.ExpirationDate?.ToString("MM/dd/yyyy") ?? "None"}",
CompanyId = currentUser.CompanyId
});
}
if (oldValues.Terms != quote.Terms)
{
changeHistories.Add(new QuoteChangeHistory
{
QuoteId = quote.Id,
ChangedByUserId = currentUser.Id,
ChangedAt = DateTime.UtcNow,
FieldName = "Terms",
OldValue = oldValues.Terms ?? "None",
NewValue = quote.Terms ?? "None",
ChangeDescription = $"Terms changed from '{oldValues.Terms ?? "None"}' to '{quote.Terms ?? "None"}'",
CompanyId = currentUser.CompanyId
});
}
if (oldValues.Notes != quote.Notes)
{
changeHistories.Add(new QuoteChangeHistory
{
QuoteId = quote.Id,
ChangedByUserId = currentUser.Id,
ChangedAt = DateTime.UtcNow,
FieldName = "Notes",
OldValue = string.IsNullOrEmpty(oldValues.Notes) ? "None" : (oldValues.Notes.Length > 50 ? oldValues.Notes.Substring(0, 50) + "..." : oldValues.Notes),
NewValue = string.IsNullOrEmpty(quote.Notes) ? "None" : (quote.Notes.Length > 50 ? quote.Notes.Substring(0, 50) + "..." : quote.Notes),
ChangeDescription = "Notes updated",
CompanyId = currentUser.CompanyId
});
}
if (oldValues.TaxPercent != quote.TaxPercent)
{
changeHistories.Add(new QuoteChangeHistory
{
QuoteId = quote.Id,
ChangedByUserId = currentUser.Id,
ChangedAt = DateTime.UtcNow,
FieldName = "Tax Percent",
OldValue = $"{oldValues.TaxPercent:N2}%",
NewValue = $"{quote.TaxPercent:N2}%",
ChangeDescription = $"Tax percent changed from {oldValues.TaxPercent:N2}% to {quote.TaxPercent:N2}%",
CompanyId = currentUser.CompanyId
});
}
// Track discount changes
if (oldValues.DiscountType != quote.DiscountType ||
oldValues.DiscountValue != quote.DiscountValue ||
oldValues.DiscountReason != quote.DiscountReason)
{
var oldDiscountDescription = FormatDiscountForHistory(
oldValues.DiscountType,
oldValues.DiscountValue,
oldValues.DiscountReason);
var newDiscountDescription = FormatDiscountForHistory(
quote.DiscountType,
quote.DiscountValue,
quote.DiscountReason);
changeHistories.Add(new QuoteChangeHistory
{
QuoteId = quote.Id,
ChangedByUserId = currentUser.Id,
ChangedAt = DateTime.UtcNow,
FieldName = "Discount",
OldValue = oldDiscountDescription,
NewValue = newDiscountDescription,
ChangeDescription = $"Discount changed from {oldDiscountDescription} to {newDiscountDescription}",
CompanyId = currentUser.CompanyId
});
}
// Track prospect field changes (if applicable)
if (!quote.CustomerId.HasValue)
{
if (oldValues.ProspectCompanyName != quote.ProspectCompanyName)
{
changeHistories.Add(new QuoteChangeHistory
{
QuoteId = quote.Id,
ChangedByUserId = currentUser.Id,
ChangedAt = DateTime.UtcNow,
FieldName = "Prospect Company Name",
OldValue = oldValues.ProspectCompanyName ?? "None",
NewValue = quote.ProspectCompanyName ?? "None",
ChangeDescription = $"Prospect company changed from '{oldValues.ProspectCompanyName ?? "None"}' to '{quote.ProspectCompanyName ?? "None"}'",
CompanyId = currentUser.CompanyId
});
}
if (oldValues.ProspectContactName != quote.ProspectContactName)
{
changeHistories.Add(new QuoteChangeHistory
{
QuoteId = quote.Id,
ChangedByUserId = currentUser.Id,
ChangedAt = DateTime.UtcNow,
FieldName = "Prospect Contact Name",
OldValue = oldValues.ProspectContactName ?? "None",
NewValue = quote.ProspectContactName ?? "None",
ChangeDescription = $"Prospect contact changed from '{oldValues.ProspectContactName ?? "None"}' to '{quote.ProspectContactName ?? "None"}'",
CompanyId = currentUser.CompanyId
});
}
if (oldValues.ProspectEmail != quote.ProspectEmail)
{
changeHistories.Add(new QuoteChangeHistory
{
QuoteId = quote.Id,
ChangedByUserId = currentUser.Id,
ChangedAt = DateTime.UtcNow,
FieldName = "Prospect Email",
OldValue = oldValues.ProspectEmail ?? "None",
NewValue = quote.ProspectEmail ?? "None",
ChangeDescription = $"Prospect email changed from '{oldValues.ProspectEmail ?? "None"}' to '{quote.ProspectEmail ?? "None"}'",
CompanyId = currentUser.CompanyId
});
}
if (oldValues.ProspectPhone != quote.ProspectPhone)
{
changeHistories.Add(new QuoteChangeHistory
{
QuoteId = quote.Id,
ChangedByUserId = currentUser.Id,
ChangedAt = DateTime.UtcNow,
FieldName = "Prospect Phone",
OldValue = oldValues.ProspectPhone ?? "None",
NewValue = quote.ProspectPhone ?? "None",
ChangeDescription = $"Prospect phone changed from '{oldValues.ProspectPhone ?? "None"}' to '{quote.ProspectPhone ?? "None"}'",
CompanyId = currentUser.CompanyId
});
}
if (oldValues.ProspectAddress != quote.ProspectAddress)
{
changeHistories.Add(new QuoteChangeHistory
{
QuoteId = quote.Id,
ChangedByUserId = currentUser.Id,
ChangedAt = DateTime.UtcNow,
FieldName = "Prospect Address",
OldValue = oldValues.ProspectAddress ?? "None",
NewValue = quote.ProspectAddress ?? "None",
ChangeDescription = $"Prospect address changed",
CompanyId = currentUser.CompanyId
});
}
}
// Save change history records
_logger.LogInformation("Change histories detected: {Count}", changeHistories.Count);
foreach (var history in changeHistories)
{
_logger.LogInformation("Adding change history: {Field} - {OldValue} -> {NewValue}",
history.FieldName, history.OldValue, history.NewValue);
await _unitOfWork.QuoteChangeHistories.AddAsync(history);
}
await _unitOfWork.Quotes.UpdateAsync(quote);
// Delete existing quote items (but capture them first for change tracking)
var existingItems = await _unitOfWork.QuoteItems.FindAsync(qi => qi.QuoteId == id);
var oldItemsForComparison = existingItems.Select(i => new
{
i.Description,
i.Quantity,
i.UnitPrice,
i.TotalPrice,
i.RequiresSandblasting,
i.RequiresMasking,
i.SurfaceAreaSqFt,
i.Notes
}).ToList();
foreach (var item in existingItems)
{
await _unitOfWork.QuoteItems.DeleteAsync(item);
}
// Create new quote items with calculated pricing
var newItemsForComparison = new List<(string Description, decimal Quantity, decimal UnitPrice, decimal TotalPrice, bool Sandblasting, bool Masking, decimal? SurfaceArea, string? Notes)>();
foreach (var itemDto in dto.QuoteItems)
{
var item = _mapper.Map<QuoteItem>(itemDto);
item.QuoteId = quote.Id;
item.CompanyId = currentUser.CompanyId;
_logger.LogInformation("Creating item: {Desc}, Sandblasting={Sand}, Masking={Mask} (from DTO: Sand={DtoSand}, Mask={DtoMask})",
item.Description, item.RequiresSandblasting, item.RequiresMasking,
itemDto.RequiresSandblasting, itemDto.RequiresMasking);
// AI items: use stored price (AI estimate or user override) — skip the pricing engine
if (itemDto.IsAiItem && itemDto.ManualUnitPrice.HasValue && itemDto.ManualUnitPrice.Value > 0)
{
item.UnitPrice = itemDto.ManualUnitPrice.Value;
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
_logger.LogInformation("AI item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
}
// Sales/merchandise items: use the manually entered price directly — no coating calculation
else if (itemDto.IsSalesItem && itemDto.ManualUnitPrice.HasValue)
{
item.UnitPrice = itemDto.ManualUnitPrice.Value;
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
_logger.LogInformation("Sales item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
}
// Catalog items: if they have coats, calculate with coats; otherwise use default price
else if (itemDto.CatalogItemId.HasValue)
{
// If catalog item has coats, calculate the full price with coat costs
if (itemDto.Coats != null && itemDto.Coats.Any())
{
_logger.LogInformation("Calculating catalog item with {CoatCount} coats", itemDto.Coats.Count);
var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, currentUser.CompanyId, ovenRateOverride);
item.UnitPrice = itemPricing.UnitPrice;
item.TotalPrice = itemPricing.TotalPrice;
item.ItemMaterialCost = itemPricing.MaterialCost;
item.ItemLaborCost = itemPricing.LaborCost;
item.ItemEquipmentCost = itemPricing.EquipmentCost;
_logger.LogInformation("Set prices: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
}
else
{
// No coats - use catalog default price
var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(itemDto.CatalogItemId.Value);
if (catalogItem != null)
{
item.UnitPrice = catalogItem.DefaultPrice;
item.TotalPrice = catalogItem.DefaultPrice * itemDto.Quantity;
_logger.LogInformation("Catalog item no coats: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
}
}
}
else
{
// Calculated items use the pricing service
_logger.LogInformation("Calculating custom item with {CoatCount} coats", itemDto.Coats?.Count ?? 0);
var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, currentUser.CompanyId, ovenRateOverride);
item.UnitPrice = itemPricing.UnitPrice;
item.TotalPrice = itemPricing.TotalPrice;
item.ItemMaterialCost = itemPricing.MaterialCost;
item.ItemLaborCost = itemPricing.LaborCost;
item.ItemEquipmentCost = itemPricing.EquipmentCost;
_logger.LogInformation("Set prices: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
}
// Flag whether the user overrode the AI's estimates before accepting
await UpdateAiPredictionOverrideAsync(itemDto, item.UnitPrice);
await _unitOfWork.QuoteItems.AddAsync(item);
// Map coats for this item with calculated costs
if (itemDto.Coats != null && itemDto.Coats.Any())
{
item.Coats = new List<QuoteItemCoat>();
for (int coatIndex = 0; coatIndex < itemDto.Coats.Count; coatIndex++)
{
var coatDto = itemDto.Coats[coatIndex];
var coat = _mapper.Map<QuoteItemCoat>(coatDto);
coat.CompanyId = currentUser.CompanyId;
// Calculate and store the coat costs
var coatPricing = await _pricingService.CalculateCoatPriceAsync(
coatDto,
itemDto.SurfaceAreaSqFt,
itemDto.Quantity,
coatIndex,
itemDto.EstimatedMinutes,
currentUser.CompanyId);
coat.CoatMaterialCost = coatPricing.CoatMaterialCost;
coat.CoatLaborCost = coatPricing.CoatLaborCost;
coat.CoatTotalCost = coatPricing.CoatTotalCost;
item.Coats.Add(coat);
}
_logger.LogInformation("Added {CoatCount} coats to item {Description}", item.Coats.Count, item.Description);
}
// Map per-item prep services
if (itemDto.PrepServices != null && itemDto.PrepServices.Any())
{
item.PrepServices = new List<QuoteItemPrepService>();
foreach (var psDto in itemDto.PrepServices)
{
var prepService = _mapper.Map<QuoteItemPrepService>(psDto);
prepService.CompanyId = currentUser.CompanyId;
item.PrepServices.Add(prepService);
}
}
// Track new item for comparison
newItemsForComparison.Add((
item.Description ?? "",
item.Quantity,
item.UnitPrice,
item.TotalPrice,
item.RequiresSandblasting,
item.RequiresMasking,
item.SurfaceAreaSqFt,
item.Notes
));
}
// Track quote item changes
_logger.LogInformation("Comparing quote items: Old={OldCount}, New={NewCount}", oldItemsForComparison.Count, newItemsForComparison.Count);
// Detect added items
if (newItemsForComparison.Count > oldItemsForComparison.Count)
{
for (int i = oldItemsForComparison.Count; i < newItemsForComparison.Count; i++)
{
var newItem = newItemsForComparison[i];
var history = new QuoteChangeHistory
{
QuoteId = quote.Id,
ChangedByUserId = currentUser.Id,
ChangedAt = DateTime.UtcNow,
FieldName = "Quote Items",
OldValue = null,
NewValue = $"{newItem.Description} (Qty: {newItem.Quantity}, Price: {newItem.TotalPrice:C})",
ChangeDescription = $"Item added: {newItem.Description}",
CompanyId = currentUser.CompanyId
};
await _unitOfWork.QuoteChangeHistories.AddAsync(history);
_logger.LogInformation("Item added: {Desc}", newItem.Description);
}
}
// Detect removed items
if (oldItemsForComparison.Count > newItemsForComparison.Count)
{
for (int i = newItemsForComparison.Count; i < oldItemsForComparison.Count; i++)
{
var oldItem = oldItemsForComparison[i];
var history = new QuoteChangeHistory
{
QuoteId = quote.Id,
ChangedByUserId = currentUser.Id,
ChangedAt = DateTime.UtcNow,
FieldName = "Quote Items",
OldValue = $"{oldItem.Description} (Qty: {oldItem.Quantity}, Price: {oldItem.TotalPrice:C})",
NewValue = null,
ChangeDescription = $"Item removed: {oldItem.Description}",
CompanyId = currentUser.CompanyId
};
await _unitOfWork.QuoteChangeHistories.AddAsync(history);
_logger.LogInformation("Item removed: {Desc}", oldItem.Description);
}
}
// Detect modified items (compare items at same index)
var itemsToCompare = Math.Min(oldItemsForComparison.Count, newItemsForComparison.Count);
for (int i = 0; i < itemsToCompare; i++)
{
var oldItem = oldItemsForComparison[i];
var newItem = newItemsForComparison[i];
var itemChanges = new List<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
if (dto.AiPhotoTempIds?.Count > 0)
{
foreach (var rawTempId in dto.AiPhotoTempIds.Where(t => !string.IsNullOrWhiteSpace(t)))
{
if (!Guid.TryParse(rawTempId, out var tempGuid)) continue;
var tempId = tempGuid.ToString("N");
var (promoted, photoPath, _) = await _photoService.PromoteTempPhotoAsync(tempId, quote.Id, currentUser.CompanyId);
if (promoted)
{
var ext = Path.GetExtension(photoPath).ToLowerInvariant();
var ct = ext switch { ".jpg" or ".jpeg" => "image/jpeg", ".png" => "image/png", ".gif" => "image/gif", ".webp" => "image/webp", _ => "image/jpeg" };
var photoEntity = new QuotePhoto
{
QuoteId = quote.Id,
TempId = tempId,
FilePath = photoPath,
FileName = Path.GetFileName(photoPath),
FileSize = 0,
ContentType = ct,
IsAiAnalysisPhoto = true,
UploadedById = currentUser.Id,
CompanyId = currentUser.CompanyId
};
await _unitOfWork.QuotePhotos.AddAsync(photoEntity);
}
}
await _unitOfWork.CompleteAsync();
}
// Promote general quote photo temp uploads (Edit page)
if (dto.QuotePhotoTempIds?.Count > 0)
{
foreach (var rawTempId in dto.QuotePhotoTempIds.Where(t => !string.IsNullOrWhiteSpace(t)))
{
if (!Guid.TryParse(rawTempId, out var tempGuid)) continue;
var tempId = tempGuid.ToString("N");
if (!await _subscriptionService.CanAddQuotePhotoAsync(currentUser.CompanyId, quote.Id)) break;
var (promoted, photoPath, _) = await _photoService.PromoteTempPhotoAsync(tempId, quote.Id, currentUser.CompanyId);
if (promoted)
{
var ext = Path.GetExtension(photoPath).ToLowerInvariant();
var ct = ext switch { ".jpg" or ".jpeg" => "image/jpeg", ".png" => "image/png", ".gif" => "image/gif", ".webp" => "image/webp", _ => "image/jpeg" };
var rawIdx = dto.QuotePhotoTempIds.IndexOf(rawTempId);
var rawFileName = (dto.QuotePhotoFileNames?.Count > rawIdx && rawIdx >= 0) ? dto.QuotePhotoFileNames[rawIdx] : Path.GetFileName(photoPath);
var photoEntity = new QuotePhoto
{
QuoteId = quote.Id,
TempId = tempId,
FilePath = photoPath,
FileName = rawFileName,
FileSize = 0,
ContentType = ct,
IsAiAnalysisPhoto = false,
UploadedById = currentUser.Id,
CompanyId = currentUser.CompanyId
};
await _unitOfWork.QuotePhotos.AddAsync(photoEntity);
}
}
await _unitOfWork.CompleteAsync();
}
_logger.LogInformation("Quote {QuoteNumber} updated successfully", quote.QuoteNumber);
this.ToastSuccess($"Quote {quote.QuoteNumber} updated successfully!");
return RedirectToAction(nameof(Details), new { id = quote.Id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating quote {QuoteId}", id);
TempData["Error"] = "An error occurred while updating the quote.";
// Re-populate customer info for display on error
if (dto.CustomerId.HasValue)
{
var customerQuote = await _unitOfWork.Quotes.GetByIdAsync(id, false, q => q.Customer);
if (customerQuote?.Customer != null)
{
ViewBag.CustomerName = customerQuote.Customer.CompanyName ?? "";
ViewBag.CustomerContactName = $"{customerQuote.Customer.ContactFirstName} {customerQuote.Customer.ContactLastName}".Trim();
ViewBag.CustomerEmail = customerQuote.Customer.Email ?? "";
ViewBag.CustomerPhone = customerQuote.Customer.Phone ?? "";
}
}
var errUser = await _userManager.GetUserAsync(User);
var errCosts = await _pricingService.GetOperatingCostsAsync(errUser!.CompanyId);
await PopulateDropDownsAsync(errUser.CompanyId, errCosts?.OvenOperatingCostPerHour ?? 0);
await SetMeasurementViewBagAsync();
return View(dto);
}
}
/// <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 != "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;
await _unitOfWork.Customers.AddAsync(customer);
await _unitOfWork.CompleteAsync();
// Get "Converted" status (cached)
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var statuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId);
var convertedStatus = statuses.FirstOrDefault(s => s.StatusCode == "CONVERTED");
// Update quote to link to new customer
quote.CustomerId = customer.Id;
// Clear prospect fields
quote.ProspectCompanyName = null;
quote.ProspectContactName = null;
quote.ProspectEmail = null;
quote.ProspectPhone = null;
quote.ProspectAddress = null;
quote.ProspectCity = null;
quote.ProspectState = null;
quote.ProspectZipCode = null;
// Update status to converted
quote.QuoteStatusId = convertedStatus?.Id ?? quote.QuoteStatusId;
await _unitOfWork.Quotes.UpdateAsync(quote);
await _unitOfWork.CompleteAsync();
this.ToastSuccess($"Prospect/Walk-In successfully converted to customer! Quote {quote.QuoteNumber} has been updated.");
return RedirectToAction("Details", "Customers", new { id = customer.Id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error converting prospect to customer for quote {QuoteId}", dto.QuoteId);
TempData["Error"] = "An error occurred while converting the prospect/walk-in to a customer.";
await PopulatePricingTiersDropDownAsync();
return View(dto);
}
}
/// <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;
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 == "APPROVED")
{
TempData["Info"] = $"Quote {quote.QuoteNumber} is already approved.";
return RedirectToAction(nameof(Details), new { id });
}
// Store old status for change history
var oldStatusName = quote.QuoteStatus.DisplayName;
// Get current user's company
var currentUser = await _userManager.GetUserAsync(User);
// Find the Approved status for this company
var approvedStatus = await _unitOfWork.QuoteStatusLookups.FirstOrDefaultAsync(
s => s.StatusCode == "APPROVED" && s.CompanyId == currentUser!.CompanyId);
if (approvedStatus == null)
{
TempData["Error"] = "Approved status not found. Please contact support.";
return RedirectToAction(nameof(Details), new { id });
}
// Update quote status
quote.QuoteStatusId = approvedStatus.Id;
quote.ApprovedDate = DateTime.UtcNow;
quote.UpdatedAt = DateTime.UtcNow;
// If previously declined by customer, clear the decline reason now that it's approved
if (!string.IsNullOrWhiteSpace(quote.DeclineReason))
quote.DeclineReason = null;
await _unitOfWork.Quotes.UpdateAsync(quote);
// Add change history entry
var changeHistory = new QuoteChangeHistory
{
QuoteId = quote.Id,
ChangedByUserId = currentUser!.Id,
ChangedAt = DateTime.UtcNow,
FieldName = "Status",
OldValue = oldStatusName,
NewValue = approvedStatus.DisplayName,
ChangeDescription = $"Quote approved by {currentUser.FirstName} {currentUser.LastName}",
CompanyId = quote.CompanyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.QuoteChangeHistories.AddAsync(changeHistory);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Quote {QuoteId} approved by user {UserId}", id, currentUser.Id);
// Notify customer that quote is approved (only if user opted in)
if (sendEmail)
{
try
{
var quoteForNotify = await _unitOfWork.Quotes.GetByIdAsync(id, false, q => q.Customer);
if (quoteForNotify != null)
await _notificationService.NotifyQuoteApprovedAsync(quoteForNotify);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Notification failed for quote {Id}", id);
}
var approveNotifLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(id);
this.SetNotificationResultToast(approveNotifLog);
}
// If this is a prospect quote, redirect straight to conversion so the
// prospect doesn't linger without becoming a customer.
if (!quote.CustomerId.HasValue)
{
this.ToastSuccess($"Quote {quote.QuoteNumber} approved! Convert the prospect/walk-in to a customer below.");
return RedirectToAction(nameof(ConvertToCustomer), new { id });
}
this.ToastSuccess($"Quote {quote.QuoteNumber} has been approved!");
return RedirectToAction(nameof(Details), new { id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error approving quote {QuoteId}", id);
TempData["Error"] = "An error occurred while approving the quote.";
return RedirectToAction(nameof(Details), new { id });
}
}
/// <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
// Customers
var customers = await _unitOfWork.Customers.GetAllAsync();
ViewBag.Customers = customers
.Select(c => new SelectListItem
{
Value = c.Id.ToString(),
Text = (!string.IsNullOrWhiteSpace(c.CompanyName)
? c.CompanyName
: $"{c.ContactFirstName} {c.ContactLastName}".Trim())
+ (c.IsTaxExempt ? " ★" : "")
})
.OrderBy(c => c.Text)
.ToList();
// Map used by JS to zero tax when a tax-exempt customer is selected
ViewBag.CustomerTaxExemptIds = customers
.Where(c => c.IsTaxExempt)
.Select(c => c.Id)
.ToHashSet();
// Map used by JS to disable the email checkbox when the customer has notifications turned off
ViewBag.CustomerEmailOptOutIds = customers
.Where(c => !c.NotifyByEmail)
.Select(c => c.Id)
.ToHashSet();
// Stored separately so views can restore the company default when switching away from an exempt customer
// (ViewBag.CompanyTaxPercent is set by the calling action if it has access to operatingCosts)
if (ViewBag.CompanyTaxPercent == null && customers.Any())
{
var costs = await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == companyId);
ViewBag.CompanyTaxPercent = costs.FirstOrDefault()?.TaxPercent ?? 0;
}
// Inventory coatings
var inventory = await _unitOfWork.InventoryItems.GetAllAsync(false, i => i.InventoryCategory);
ViewBag.InventoryCoatings = inventory
.Where(i => i.IsActive && i.InventoryCategory?.IsActive == true && i.InventoryCategory.IsCoating)
.OrderBy(i => i.InventoryCategory!.DisplayOrder).ThenBy(i => i.ColorName ?? i.Name)
.Select(i => new
{
value = i.Id.ToString(),
text = $"{i.InventoryCategory!.DisplayName} - {i.Manufacturer ?? "Generic"} - {i.ColorName ?? i.Name} - {i.ColorCode ?? "N/A"} ({i.UnitCost:C4}/unit)",
coverage = i.CoverageSqFtPerLb ?? 30m,
efficiency = i.TransferEfficiency ?? 65m,
unitOfMeasure = i.UnitOfMeasure ?? "lbs",
categoryName = i.InventoryCategory.DisplayName,
costPerLb = i.UnitCost,
colorName = i.ColorName ?? i.Name,
colorCode = i.ColorCode ?? ""
}).ToList();
// Vendors
var vendors = await _unitOfWork.Vendors.GetAllAsync(false);
ViewBag.Vendors = vendors
.Where(s => s.IsActive).OrderBy(s => s.CompanyName)
.Select(s => new { value = s.Id.ToString(), text = s.CompanyName }).ToList();
// Catalog items
var catalogItems = await _unitOfWork.CatalogItems.GetAllAsync(false, i => i.Category, i => i.Category.ParentCategory);
ViewBag.CatalogItems = catalogItems
.Where(i => i.IsActive)
.OrderBy(i => i.Category.DisplayOrder).ThenBy(i => i.DisplayOrder)
.Select(i => new
{
value = i.Id.ToString(),
text = BuildCatalogItemDisplayText(i),
categoryName = i.Category.Name,
price = i.DefaultPrice,
approxArea = i.ApproximateArea ?? 0m,
defaultMinutes = i.DefaultEstimatedMinutes ?? 0,
thumbnailPath = i.ThumbnailPath
}).ToList();
// Merchandise items (IsMerchandise = true) — for the sales wizard step
ViewBag.MerchandiseItems = catalogItems
.Where(i => i.IsActive && i.IsMerchandise)
.OrderBy(i => i.Category.Name).ThenBy(i => i.DisplayOrder).ThenBy(i => i.Name)
.Select(i => new
{
id = i.Id,
name = i.Name,
sku = i.SKU,
category = i.Category.Name,
price = i.DefaultPrice,
description = i.Description
}).ToList();
// Prep services
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive);
ViewBag.PrepServices = prepServices.OrderBy(ps => ps.DisplayOrder).ToList();
// Blast setups for wizard dropdown
var blastSetups = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive);
ViewBag.BlastSetups = blastSetups.OrderBy(b => b.DisplayOrder)
.Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault })
.ToList();
// Quote statuses
var quoteStatuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId);
ViewBag.QuoteStatuses = new SelectList(
quoteStatuses.Where(s => s.IsActive).OrderBy(s => s.DisplayOrder), "Id", "DisplayName");
// Oven costs
var ovens = await _unitOfWork.OvenCosts.FindAsync(o => o.IsActive && o.CompanyId == companyId);
var ovenItems = new List<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 pricingTiers = await _unitOfWork.PricingTiers.GetAllAsync();
ViewBag.PricingTiers = pricingTiers.OrderBy(pt => pt.TierName)
.Select(pt => new SelectListItem
{
Value = pt.Id.ToString(),
Text = $"{pt.TierName} ({pt.DiscountPercent}% discount)"
}).ToList();
}
/// <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;
}
}
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
};
return await _pdfService.GenerateQuotePdfAsync(
quoteDto,
company.LogoData,
company.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 == "APPROVED" && oldStatusCode != "APPROVED")
{
quote.ApprovedDate = DateTime.UtcNow;
}
await _unitOfWork.Quotes.UpdateAsync(quote);
await _unitOfWork.SaveChangesAsync();
// Auto-create job when quote is approved — guard against double-conversion
// (race condition: two simultaneous approval calls could both pass the oldStatusCode check)
if (newStatus.StatusCode == "APPROVED" && oldStatusCode != "APPROVED"
&& !quote.ConvertedToJobId.HasValue)
{
try
{
await CreateJobFromQuote(quote);
}
catch (Exception jobEx)
{
_logger.LogError(jobEx, "Error creating job from approved quote {QuoteId}", request.QuoteId);
// Don't fail the status update if job creation fails
return Json(new
{
success = true,
status = newStatus.DisplayName,
warning = "Quote approved but job creation failed. Please create the job manually."
});
}
}
return Json(new { success = true, status = newStatus.DisplayName });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating status for quote {QuoteId}", request.QuoteId);
return Json(new { success = false, message = "An error occurred while updating the status" });
}
}
/// <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
var jobStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
var jobPriorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
var approvedStatus = jobStatuses.FirstOrDefault(s => s.StatusCode == "APPROVED");
var normalPriority = jobPriorities.FirstOrDefault(p => p.PriorityCode == "NORMAL");
var rushPriority = jobPriorities.FirstOrDefault(p => p.PriorityCode == "RUSH");
// Use Rush priority if quote is a rush job, otherwise use Normal
var selectedPriority = quote.IsRushJob && rushPriority != null ? rushPriority : normalPriority;
job = new Job
{
JobNumber = await GenerateJobNumberAsync(),
CustomerId = quote.CustomerId ?? 0, // Should always have a customer by approval time
QuoteId = quote.Id,
OvenCostId = quote.OvenCostId, // Carry oven selection from quote
Description = quote.Description ?? $"Job from Quote {quote.QuoteNumber}",
JobStatusId = approvedStatus?.Id ?? 1,
JobPriorityId = selectedPriority?.Id ?? 1,
QuotedPrice = quote.Total,
FinalPrice = quote.Total,
CustomerPO = quote.CustomerPO,
InternalNotes = quote.Notes, // Copy internal notes from quote
IsCustomerApproved = true,
IsRushJob = quote.IsRushJob,
DiscountType = quote.DiscountType,
DiscountValue = quote.DiscountValue,
DiscountReason = quote.DiscountReason,
CompanyId = quote.CompanyId,
CreatedAt = DateTime.UtcNow,
QuoteSnapshotUpdatedAt = quote.UpdatedAt ?? quote.CreatedAt
};
await _unitOfWork.Jobs.AddAsync(job);
await _unitOfWork.SaveChangesAsync();
// Create job items from quote items
foreach (var quoteItem in fullItems.Where(qi => !qi.IsDeleted))
{
// Get first coat's color information if available
var firstCoat = quoteItem.Coats?.OrderBy(c => c.Sequence).FirstOrDefault();
var jobItem = new JobItem
{
JobId = job.Id,
Description = quoteItem.Description,
Quantity = quoteItem.Quantity,
ColorName = firstCoat?.ColorName,
ColorCode = firstCoat?.ColorCode,
Finish = firstCoat?.Finish,
SurfaceArea = quoteItem.SurfaceAreaSqFt,
SurfaceAreaSqFt = quoteItem.SurfaceAreaSqFt,
CatalogItemId = quoteItem.CatalogItemId,
IsGenericItem = quoteItem.IsGenericItem,
IsLaborItem = quoteItem.IsLaborItem,
IsSalesItem = quoteItem.IsSalesItem,
Sku = quoteItem.Sku,
ManualUnitPrice = quoteItem.ManualUnitPrice,
PowderCostOverride = quoteItem.PowderCostOverride,
UnitPrice = quoteItem.UnitPrice,
TotalPrice = quoteItem.TotalPrice,
LaborCost = quoteItem.TotalPrice * 0.4m, // Estimated 40% labor cost
RequiresSandblasting = quoteItem.RequiresSandblasting,
RequiresMasking = quoteItem.RequiresMasking,
EstimatedMinutes = quoteItem.EstimatedMinutes,
Notes = quoteItem.Notes,
Complexity = quoteItem.Complexity,
AiTags = quoteItem.AiTags,
AiPredictionId = quoteItem.AiPredictionId, // Share the same prediction record — no duplication
// Catalog items are fixed-price — prep services must not add labor cost to them.
// Non-catalog items default to true so prep service labor is included in the calculated price.
IncludePrepCost = !quoteItem.CatalogItemId.HasValue,
CompanyId = quote.CompanyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.JobItems.AddAsync(jobItem);
await _unitOfWork.SaveChangesAsync(); // Save JobItem first to get its ID
// Create JobItemCoat records for all coats from quote
if (quoteItem.Coats != null && quoteItem.Coats.Any())
{
foreach (var quoteCoat in quoteItem.Coats.OrderBy(c => c.Sequence))
{
// Get color info from inventory item if available, otherwise use coat fields
string colorName = quoteCoat.ColorName;
string colorCode = quoteCoat.ColorCode;
string finish = quoteCoat.Finish;
if (quoteCoat.InventoryItemId.HasValue && quoteCoat.InventoryItem != null)
{
// Use inventory item information (takes precedence)
colorName = quoteCoat.InventoryItem.Name;
colorCode = quoteCoat.InventoryItem.ColorCode;
finish = quoteCoat.InventoryItem.Finish;
}
// Calculate PowderToOrder if not already stored on the quote coat
var cov = quoteCoat.CoverageSqFtPerLb > 0 ? quoteCoat.CoverageSqFtPerLb : 30m;
var eff = quoteCoat.TransferEfficiency > 0 ? quoteCoat.TransferEfficiency / 100m : 0.65m;
var powderToOrder = (quoteCoat.PowderToOrder > 0)
? quoteCoat.PowderToOrder
: (quoteItem.SurfaceAreaSqFt > 0
? Math.Round((quoteItem.SurfaceAreaSqFt * quoteItem.Quantity) / (cov * eff), 2)
: (decimal?)null);
var jobCoat = new JobItemCoat
{
JobItemId = jobItem.Id,
CoatName = quoteCoat.CoatName,
Sequence = quoteCoat.Sequence,
InventoryItemId = quoteCoat.InventoryItemId,
ColorName = colorName,
VendorId = quoteCoat.VendorId,
ColorCode = colorCode,
Finish = finish,
CoverageSqFtPerLb = quoteCoat.CoverageSqFtPerLb,
TransferEfficiency = quoteCoat.TransferEfficiency,
PowderCostPerLb = quoteCoat.PowderCostPerLb,
PowderToOrder = powderToOrder,
Notes = quoteCoat.Notes,
CompanyId = quote.CompanyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.JobItemCoats.AddAsync(jobCoat);
_logger.LogInformation("Created JobItemCoat '{CoatName}' (Sequence {Seq}) - Color: {Color} ({Code})",
jobCoat.CoatName, jobCoat.Sequence, colorName ?? "N/A", colorCode ?? "N/A");
}
}
}
await _unitOfWork.SaveChangesAsync();
// Aggregate unique prep services from all quote items and copy to job
// Load from DB directly to ensure prep services are available regardless of caller's includes
var quoteItemIds = fullItems.Select(qi => qi.Id).ToList();
var itemPrepServices = (await _unitOfWork.QuoteItemPrepServices.FindAsync(
ps => quoteItemIds.Contains(ps.QuoteItemId))).ToList();
var uniquePrepServiceIds = itemPrepServices
.Select(ps => ps.PrepServiceId)
.Distinct()
.ToList();
if (uniquePrepServiceIds.Any())
{
foreach (var prepServiceId in uniquePrepServiceIds)
{
await _unitOfWork.JobPrepServices.AddAsync(new JobPrepService
{
JobId = job.Id,
PrepServiceId = prepServiceId,
CompanyId = quote.CompanyId,
CreatedAt = DateTime.UtcNow
});
}
await _unitOfWork.SaveChangesAsync();
_logger.LogInformation("Copied {Count} unique prep services to job {JobNumber}",
uniquePrepServiceIds.Count, job.JobNumber);
}
// Update quote to track the conversion
quote.ConvertedToJobId = job.Id;
quote.ConvertedDate = DateTime.UtcNow;
await _unitOfWork.SaveChangesAsync();
// Copy all quote photos to job (leave originals on the quote)
// AI analysis photos are copied with IsAiAnalysisPhoto=true so they don't count against subscription limits
try
{
var quotePhotos = (await _unitOfWork.QuotePhotos.FindAsync(
p => p.QuoteId == quote.Id && !p.IsDeleted, ignoreQueryFilters: true)).ToList();
foreach (var qp in quotePhotos)
{
var (readOk, photoBytes, _) = await _photoService.ReadPhotoAsync(qp.FilePath);
if (!readOk || photoBytes.Length == 0) continue;
using var ms = new MemoryStream(photoBytes);
var formFile = new FormFile(ms, 0, photoBytes.Length, "photo", qp.FileName)
{
Headers = new HeaderDictionary(),
ContentType = qp.ContentType
};
var caption = qp.IsAiAnalysisPhoto ? "AI Quote Photo" : "Quote Photo";
var (saved, jobPhotoPath, _) = await _jobPhotoService.SaveJobPhotoAsync(
formFile, job.Id, quote.CompanyId, caption, JobPhotoType.Before);
if (saved)
{
await _unitOfWork.JobPhotos.AddAsync(new JobPhoto
{
JobId = job.Id,
CompanyId = quote.CompanyId,
FilePath = jobPhotoPath,
FileName = qp.FileName,
FileSize = qp.FileSize,
ContentType = qp.ContentType,
Caption = caption,
PhotoType = JobPhotoType.Before,
IsAiAnalysisPhoto = qp.IsAiAnalysisPhoto,
UploadedById = qp.UploadedById ?? string.Empty,
CreatedAt = DateTime.UtcNow
});
}
}
await _unitOfWork.SaveChangesAsync();
}
catch (Exception photoEx)
{
_logger.LogWarning(photoEx, "Failed to copy quote photos to job {JobNumber} (non-fatal)", job.JobNumber);
}
}); // end ExecuteInTransactionAsync
_logger.LogInformation("Successfully created job {JobNumber} from quote {QuoteNumber}",
job.JobNumber, quote.QuoteNumber);
}
/// <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)
{
try
{
var quote = await _unitOfWork.Quotes.GetByIdAsync(id, false, q => q.Customer, q => q.QuoteStatus);
if (quote == null)
return Json(new { success = false, message = "Quote not found." });
// Determine recipient for feedback message
string? recipientEmail = quote.CustomerId.HasValue
? quote.Customer?.Email
: quote.ProspectEmail;
string recipientName = quote.CustomerId.HasValue && quote.Customer != null
? (!string.IsNullOrWhiteSpace(quote.Customer.CompanyName)
? quote.Customer.CompanyName
: $"{quote.Customer.ContactFirstName} {quote.Customer.ContactLastName}".Trim())
: (!string.IsNullOrWhiteSpace(quote.ProspectContactName) ? quote.ProspectContactName
: quote.ProspectCompanyName ?? "Prospect");
if (string.IsNullOrWhiteSpace(recipientEmail))
return Json(new { success = false, message = "No email address on file for this quote's recipient." });
var currentUser = await _userManager.GetUserAsync(User);
// Generate PDF attachment
byte[]? pdfBytes = null;
string? pdfFilename = null;
try
{
pdfBytes = await BuildQuotePdfAsync(id, currentUser);
pdfFilename = $"Quote-{quote.QuoteNumber}.pdf";
}
catch (Exception pdfEx)
{
_logger.LogWarning(pdfEx, "PDF generation failed for resend of quote {Id}; sending without attachment", id);
}
// Regenerate approval token (invalidates old link)
var resendTokenBytes = System.Security.Cryptography.RandomNumberGenerator.GetBytes(32);
quote.ApprovalToken = Convert.ToBase64String(resendTokenBytes)
.Replace('+', '-').Replace('/', '_').TrimEnd('=');
quote.ApprovalTokenExpiresAt = DateTime.UtcNow.AddDays(
int.TryParse(await _platformSettings.GetAsync(PlatformSettingKeys.QuoteApprovalTokenDays), out var tokenDays) ? tokenDays : 30);
quote.ApprovalTokenUsedAt = null;
await _unitOfWork.Quotes.UpdateAsync(quote);
await _unitOfWork.CompleteAsync();
await _notificationService.NotifyQuoteSentAsync(quote, pdfBytes, pdfFilename);
// Check the most recent log entry to get actual send status
var latestLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(id);
if (latestLog?.Status == NotificationStatus.Failed)
return Json(new { success = false, message = $"Email delivery failed: {latestLog.ErrorMessage}" });
if (latestLog?.Status == NotificationStatus.Skipped)
return Json(new { success = false, message = $"{recipientName} has email notifications disabled or no email address on file." });
return Json(new { success = true, message = $"Quote sent to {recipientName} ({recipientEmail})." });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error resending quote {QuoteId}", id);
return Json(new { success = false, message = "An unexpected error occurred. Please try again." });
}
}
/// <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]
[ValidateAntiForgeryToken]
[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]
[ValidateAntiForgeryToken]
[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);
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;
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>
/// After pricing is determined for an AI item, update the prediction record to flag whether
/// the user changed the AI's estimated surface area or unit price before accepting.
/// This data powers the "AI accuracy" reporting queries.
/// </summary>
private async Task UpdateAiPredictionOverrideAsync(CreateQuoteItemDto itemDto, decimal finalUnitPrice)
{
if (!itemDto.AiPredictionId.HasValue) return;
var prediction = await _unitOfWork.AiItemPredictions.GetByIdAsync(itemDto.AiPredictionId.Value);
if (prediction == null) return;
var sqftDiff = Math.Abs(prediction.PredictedSurfaceAreaSqFt - itemDto.SurfaceAreaSqFt);
var priceDiff = Math.Abs(prediction.PredictedUnitPrice - finalUnitPrice);
prediction.UserOverrodeEstimate = sqftDiff > 0.01m || priceDiff > 0.01m;
prediction.UpdatedAt = DateTime.UtcNow;
// Change is tracked by EF; will be persisted on the next CompleteAsync()
}
/// <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 == "COMPLETED" || s.StatusCode == "DELIVERED"))
.Select(s => s.Id).ToHashSet();
var completedJobs = await _unitOfWork.Jobs.FindAsync(
j => jobIds.Contains(j.Id) && completedStatusIds.Contains(j.JobStatusId));
var completedJobIds = completedJobs.Select(j => j.Id).ToHashSet();
var completed = matches.Where(ji => completedJobIds.Contains(ji.JobId)).ToList();
if (completed.Count == 0) return null;
return new AiBenchmarkResult
{
MatchCount = completed.Count,
MinPrice = completed.Min(ji => ji.UnitPrice),
MaxPrice = completed.Max(ji => ji.UnitPrice),
AvgPrice = completed.Average(ji => ji.UnitPrice),
ComplexityLevel = complexity,
SqFtRangeMin = sqFtMin,
SqFtRangeMax = sqFtMax
};
}
catch
{
return null; // benchmark is optional — never block the analysis
}
}
private static string FormatFileSize(long bytes)
{
if (bytes < 1024) return $"{bytes} B";
if (bytes < 1024 * 1024) return $"{bytes / 1024.0:F1} KB";
return $"{bytes / (1024.0 * 1024):F1} MB";
}
/// <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 });
}
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);
}
}
// 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; }
}