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