Add Custom Powder Order line item and fix CSV import FinalPrice crash

Custom powder/incoming powder material cost now flows into a separate
auto-generated 'Custom Powder Order' line item instead of rolling into
individual item prices, so users can add shipping charges before the
customer sees the total. A dashed yellow preview card in the wizard
shows the material cost and lets users edit the total (including shipping)
before saving. After first save the price is user-owned.

Also fixes a fatal CSV import crash when FinalPrice contains a non-numeric
value (e.g. 'false' from a spreadsheet formula): the job CSV importer now
streams rows one at a time with a lenient decimal converter, treating bad
values as $0 with a per-row warning instead of aborting the entire import.

Updated HelpKnowledgeBase.cs and Help articles (Jobs, Quotes) with
Custom Powder Order behavior and a new Data Import / Export section.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 23:37:46 -04:00
parent e476b4744d
commit a7ad0e1de8
19 changed files with 721 additions and 78 deletions
@@ -884,4 +884,9 @@ public class QuotePricingResult
// Per-item results (same order as input items)
public List<QuoteItemPricingResult> ItemResults { get; set; } = new();
// Pending Custom Powder Order preview — populated only when no "Custom Powder Order" item
// exists yet (first save scenario). Amount and color list let the UI show a preview row.
public decimal CustomPowderOrderAmount { get; set; }
public List<string> CustomPowderOrderColors { get; set; } = new();
}
@@ -192,7 +192,10 @@ public class QuoteProfile : Profile
.ForMember(dest => dest.UpdatedBy, opt => opt.Ignore());
// QuoteItem -> CreateQuoteItemDto (for Edit view)
// Coats and PrepServices must be mapped explicitly; convention-based collection mapping
// is unreliable for ICollection<T> → List<T2> with different element types.
CreateMap<QuoteItem, CreateQuoteItemDto>()
.ForMember(dest => dest.Coats, opt => opt.MapFrom(src => src.Coats))
.ForMember(dest => dest.PrepServices, opt => opt.MapFrom(src => src.PrepServices));
// ============================================================================
@@ -220,6 +220,16 @@ public class PricingCalculationService : IPricingCalculationService
};
}
/// <summary>
/// Returns true when a coat requires ordering custom powder that is not in inventory.
/// Only coats with an explicit PowderToOrder quantity qualify — coats without a quantity
/// fall through to the standard surface-area pricing path in CalculateCoatPriceAsync.
/// </summary>
private static bool IsCustomPowderCoat(CreateQuoteItemCoatDto coat) =>
!coat.InventoryItemId.HasValue &&
coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0 &&
coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0;
/// <summary>
/// Calculates the total price for a single quote line item, routing to the correct pricing
/// path based on item type:
@@ -289,20 +299,23 @@ public class PricingCalculationService : IPricingCalculationService
}
// Custom formula items (FixedRate mode): the wizard evaluated the NCalc formula server-side
// and stored the result as ManualUnitPrice. Use it directly — no coating math.
// and stored the result as ManualUnitPrice. The formula result IS the total price — it already
// incorporates any quantity-like fields the user entered (e.g. numWheels, numParts). Do NOT
// multiply by Quantity again; doing so double-counts when the formula itself accounts for qty.
// SurfaceAreaSqFt mode: ManualUnitPrice is null; the formula produced sqft which was stored
// in SurfaceAreaSqFt, so the item falls through to the standard calculated path below.
if (item.IsCustomFormulaItem && item.ManualUnitPrice.HasValue)
{
var total = item.ManualUnitPrice.Value * item.Quantity;
var formulaTotal = item.ManualUnitPrice.Value;
var formulaUnitPrice = item.Quantity > 0 ? formulaTotal / item.Quantity : formulaTotal;
return new QuoteItemPricingResult
{
MaterialCost = 0,
LaborCost = 0,
EquipmentCost = 0,
ItemSubtotal = total,
UnitPrice = item.ManualUnitPrice.Value,
TotalPrice = total
ItemSubtotal = formulaTotal,
UnitPrice = formulaUnitPrice,
TotalPrice = formulaTotal
};
}
@@ -330,6 +343,8 @@ public class PricingCalculationService : IPricingCalculationService
{
for (int i = 0; i < item.Coats.Count; i++)
{
// Custom powder material moves to the "Custom Powder Order" line item
if (IsCustomPowderCoat(item.Coats[i])) continue;
var coatResult = await CalculateCoatPriceAsync(
item.Coats[i], 0m, item.Quantity, i, 0, companyId);
coatMaterialCost += coatResult.CoatMaterialCost;
@@ -431,7 +446,9 @@ public class PricingCalculationService : IPricingCalculationService
for (int ci = 0; ci < item.Coats.Count; ci++)
{
var coat = item.Coats[ci];
if (!coat.InventoryItemId.HasValue && coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0)
// Custom powder with PowderToOrder moves to the "Custom Powder Order" line item; skip here
if (!coat.InventoryItemId.HasValue && coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0
&& !IsCustomPowderCoat(coat))
{
var coatResult = await CalculateCoatPriceAsync(coat, item.SurfaceAreaSqFt, item.Quantity, ci, 0, companyId);
totalMaterialCost += coatResult.CoatMaterialCost;
@@ -449,7 +466,8 @@ public class PricingCalculationService : IPricingCalculationService
{
var firstCoatResult = await CalculateCoatPriceAsync(
item.Coats[0], item.SurfaceAreaSqFt, item.Quantity, 0, item.EstimatedMinutes, companyId);
totalMaterialCost = firstCoatResult.CoatMaterialCost;
// Custom powder material moves to the "Custom Powder Order" line item; keep the labor
totalMaterialCost = IsCustomPowderCoat(item.Coats[0]) ? 0m : firstCoatResult.CoatMaterialCost;
coatLaborCost = firstCoatResult.CoatLaborCost;
totalLaborCost = coatLaborCost;
}
@@ -646,6 +664,49 @@ public class PricingCalculationService : IPricingCalculationService
// 4. TOTAL ITEMS SUBTOTAL
var itemsSubtotal = catalogItemsWithoutCoatsTotal + calculatedItemsSubtotal;
// Powder-to-order costs are excluded from individual item prices and collected in a
// "Custom Powder Order" line item added at save time. For live pricing previews (before
// save), add them back here so the displayed total stays correct throughout the session.
// Two coat types qualify: custom powder (no InventoryItemId, manual PowderCostPerLb) and
// incoming powder (InventoryItemId set, IsIncoming=true, cost from inventoryItem.UnitCost).
bool hasCustomPowderOrderItem = items.Any(i =>
i.IsGenericItem && i.Description?.StartsWith("Custom Powder Order") == true);
decimal customPowderOrderAmount = 0m;
var customPowderOrderColors = new List<string>();
if (!hasCustomPowderOrderItem)
{
foreach (var item in items.Where(i => i.Coats != null))
{
foreach (var c in item.Coats!)
{
if (!c.InventoryItemId.HasValue &&
c.PowderToOrder.HasValue && c.PowderToOrder.Value > 0 &&
c.PowderCostPerLb.HasValue && c.PowderCostPerLb.Value > 0)
{
customPowderOrderAmount += c.PowderToOrder.Value * c.PowderCostPerLb.Value;
if (!string.IsNullOrWhiteSpace(c.ColorName))
customPowderOrderColors.Add(c.ColorName);
}
else if (c.InventoryItemId.HasValue && c.PowderToOrder.HasValue && c.PowderToOrder.Value > 0)
{
var invItem = await _unitOfWork.InventoryItems.GetByIdAsync(c.InventoryItemId.Value);
if (invItem?.IsIncoming == true)
{
customPowderOrderAmount += c.PowderToOrder.Value * invItem.UnitCost;
var colorName = !string.IsNullOrWhiteSpace(c.ColorName) ? c.ColorName : invItem.Name;
if (!string.IsNullOrWhiteSpace(colorName))
customPowderOrderColors.Add(colorName);
}
}
}
}
if (customPowderOrderAmount > 0)
{
itemsSubtotal += customPowderOrderAmount;
totalMaterialCosts += customPowderOrderAmount;
}
}
// 4b. OVEN BATCH COST (quote-level: batches × cycle time × oven rate)
// AI items already have oven cost baked into their AI-estimated price, so we only
// charge the proportion of the oven that's attributable to non-AI items.
@@ -824,7 +885,11 @@ public class PricingCalculationService : IPricingCalculationService
MaterialCosts = Math.Round(totalMaterialCosts, 2),
LaborCosts = Math.Round(totalLaborCosts, 2),
EquipmentCosts = Math.Round(totalEquipmentCosts, 2),
ItemResults = itemResults
ItemResults = itemResults,
CustomPowderOrderAmount = Math.Round(customPowderOrderAmount, 2),
CustomPowderOrderColors = customPowderOrderColors
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList()
};
}
}
@@ -90,8 +90,9 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
{
ArgumentNullException.ThrowIfNull(itemDtos);
var dtoList = itemDtos.ToList();
var items = new List<QuoteItem>();
foreach (var itemDto in itemDtos)
foreach (var itemDto in dtoList)
{
var item = BuildQuoteItem(itemDto, quoteId, companyId, createdAtUtc);
await ApplyPricingAsync(item, itemDto, companyId, ovenRateOverride);
@@ -102,6 +103,17 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
items.Add(item);
}
// Option B: auto-create the Custom Powder Order item only on first save.
// Once user-owned, they manage its price (e.g. to add shipping) — we never overwrite it.
bool hasExistingCustomPowderOrder = dtoList.Any(d =>
d.IsGenericItem && d.Description?.StartsWith("Custom Powder Order") == true);
if (!hasExistingCustomPowderOrder)
{
var customPowderItem = await BuildCustomPowderOrderItemAsync(dtoList, quoteId, companyId, createdAtUtc);
if (customPowderItem != null)
items.Add(customPowderItem);
}
return items;
}
@@ -461,4 +473,78 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
return null;
}
}
/// <summary>
/// Scans all coat DTOs for powder that must be ordered (custom or incoming) and returns a
/// single "Custom Powder Order" QuoteItem aggregating all material costs and color names.
/// Returns null when no such coats are found. Used by <see cref="CreateQuoteItemsAsync"/>
/// on the first save only — Option B means the user owns the price after creation.
///
/// Two coat types qualify:
/// - Custom powder: no InventoryItemId, manual PowderCostPerLb &gt; 0
/// - Incoming powder: InventoryItemId set but inventoryItem.IsIncoming == true
/// (auto-created by <see cref="CreateIncomingInventoryItemAsync"/>; PowderCostPerLb cleared
/// after creation, so cost comes from inventoryItem.UnitCost instead)
/// </summary>
private async Task<QuoteItem?> BuildCustomPowderOrderItemAsync(
IReadOnlyList<CreateQuoteItemDto> itemDtos, int quoteId, int companyId, DateTime createdAtUtc)
{
var colorNames = new List<string>();
decimal totalCost = 0m;
foreach (var itemDto in itemDtos)
{
if (itemDto.Coats == null) continue;
foreach (var coat in itemDto.Coats)
{
if (!coat.InventoryItemId.HasValue &&
coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0 &&
coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0)
{
// Custom powder: no inventory link, user entered cost per lb manually
totalCost += coat.PowderToOrder.Value * coat.PowderCostPerLb.Value;
if (!string.IsNullOrWhiteSpace(coat.ColorName))
colorNames.Add(coat.ColorName);
}
else if (coat.InventoryItemId.HasValue && coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
{
// Incoming powder: catalog-selected; CreateIncomingInventoryItemAsync set InventoryItemId
// and cleared PowderCostPerLb, so cost must come from the inventory item's UnitCost
var invItem = await _unitOfWork.InventoryItems.GetByIdAsync(coat.InventoryItemId.Value);
if (invItem?.IsIncoming == true)
{
totalCost += coat.PowderToOrder.Value * invItem.UnitCost;
var colorName = !string.IsNullOrWhiteSpace(coat.ColorName) ? coat.ColorName : invItem.Name;
if (!string.IsNullOrWhiteSpace(colorName))
colorNames.Add(colorName);
}
}
}
}
if (totalCost <= 0) return null;
var uniqueColors = colorNames
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var description = uniqueColors.Any()
? $"Custom Powder Order ({string.Join(", ", uniqueColors)})"
: "Custom Powder Order";
return new QuoteItem
{
QuoteId = quoteId,
Description = description,
Quantity = 1,
IsGenericItem = true,
ManualUnitPrice = totalCost,
UnitPrice = totalCost,
TotalPrice = totalCost,
ItemMaterialCost = totalCost,
CompanyId = companyId,
CreatedAt = createdAtUtc,
Coats = [],
PrepServices = []
};
}
}
@@ -1268,24 +1268,22 @@ public class CsvImportService : ICsvImportService
MissingFieldFound = null
});
var records = csv.GetRecords<JobImportDto>().ToList();
result.TotalRows = records.Count;
// Treat non-numeric values in decimal? fields (e.g. a spreadsheet "false" in FinalPrice)
// as null rather than throwing a fatal TypeConverterException.
csv.Context.TypeConverterCache.AddConverter<decimal?>(new LenientNullableDecimalConverter());
_logger.LogInformation("Starting import of {Count} jobs for company {CompanyId}", records.Count, companyId);
// Read header row first so we know field count before iterating rows.
await csv.ReadAsync();
csv.ReadHeader();
// Get all existing jobs for duplicate detection
// Pre-load lookup data before streaming rows so async calls don't interleave with CSV reads.
var existingJobs = await _unitOfWork.Jobs.GetAllAsync();
var existingJobNumbers = existingJobs.Where(j => !string.IsNullOrEmpty(j.JobNumber))
.ToDictionary(j => j.JobNumber.ToUpper(), j => j, StringComparer.OrdinalIgnoreCase);
// Get customers for lookup — build two dictionaries so we can resolve by email
// first and fall back to company name when the customer has no email on file.
var customers = await _unitOfWork.Customers.GetAllAsync();
var customerByEmail = customers.Where(c => !string.IsNullOrEmpty(c.Email))
.ToDictionary(c => c.Email!.Trim().ToLower(), c => c, StringComparer.OrdinalIgnoreCase);
// Name fallback: keyed on CompanyName (commercial) or "First Last" (non-commercial).
// TryAdd ensures that if two customers share the same name the first one wins and the
// lookup warning will prompt the user to resolve the ambiguity manually.
var customerByName = new Dictionary<string, Customer>(StringComparer.OrdinalIgnoreCase);
foreach (var c in customers)
{
@@ -1296,19 +1294,42 @@ public class CsvImportService : ICsvImportService
customerByName.TryAdd(name, c);
}
// Get job statuses for lookup
var jobStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
var jobStatusDict = jobStatuses.ToDictionary(js => js.StatusCode.ToUpper(), js => js, StringComparer.OrdinalIgnoreCase);
// Get job priorities for lookup
var jobPriorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
var jobPriorityDict = jobPriorities.ToDictionary(jp => jp.PriorityCode.ToUpper(), jp => jp, StringComparer.OrdinalIgnoreCase);
var jobsToImport = new List<(int RowNumber, Job Job, string JobNumber)>();
foreach (var record in records)
// Stream rows one at a time so a bad type conversion on a single row (e.g. "false"
// in a decimal field) is caught per-row rather than aborting the entire import.
while (await csv.ReadAsync())
{
rowNumber++;
result.TotalRows++;
JobImportDto record;
try
{
record = csv.GetRecord<JobImportDto>()
?? throw new InvalidOperationException("Row returned null record.");
}
catch (Exception parseEx)
{
result.Errors.Add($"Row {csv.Context.Parser?.Row}: Could not parse row - {parseEx.InnerException?.Message ?? parseEx.Message}");
result.ErrorCount++;
_logger.LogWarning(parseEx, "Parse error at CSV row {Row}", csv.Context.Parser?.Row);
continue;
}
rowNumber = csv.Context.Parser?.Row ?? rowNumber + 1;
// Warn when FinalPrice was non-numeric (lenient converter returned null).
var rawFinalPrice = csv.TryGetField<string>(7, out var fpStr) ? fpStr : null;
if (!string.IsNullOrWhiteSpace(rawFinalPrice) && record.FinalPrice == null
&& !decimal.TryParse(rawFinalPrice, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out _))
{
result.Warnings.Add($"Row {rowNumber}: FinalPrice value '{rawFinalPrice}' could not be parsed as a number; defaulting to $0.");
}
try
{
// Validate required fields
@@ -3340,4 +3361,23 @@ public class CsvImportService : ICsvImportService
return result;
}
}
/// <summary>
/// Returns null for any value that cannot be parsed as a decimal, instead of throwing a
/// TypeConverterException. Registered globally on the job CSV reader so that spreadsheet
/// artefacts like "false" in a price column are treated as $0 with a warning.
/// </summary>
private sealed class LenientNullableDecimalConverter : CsvHelper.TypeConversion.ITypeConverter
{
public object? ConvertFromString(string? text, CsvHelper.IReaderRow row, CsvHelper.Configuration.MemberMapData memberMapData)
{
if (string.IsNullOrWhiteSpace(text)) return null;
return decimal.TryParse(text, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var v)
? (object?)v
: null;
}
public string? ConvertToString(object? value, CsvHelper.IWriterRow row, CsvHelper.Configuration.MemberMapData memberMapData)
=> value?.ToString();
}
}
@@ -1176,6 +1176,26 @@ public class JobsController : Controller
}
}
// Option B: auto-add Custom Powder Order item on first save if not already present
var allCreateItems = dto.JobItems.ToList();
if (!allCreateItems.Any(d => d.IsGenericItem && d.Description?.StartsWith("Custom Powder Order") == true))
{
var powderDto = await BuildCustomPowderOrderDto(allCreateItems);
if (powderDto != null)
{
var pp = new QuoteItemPricingResult
{
UnitPrice = powderDto.ManualUnitPrice!.Value, TotalPrice = powderDto.ManualUnitPrice!.Value,
ItemSubtotal = powderDto.ManualUnitPrice!.Value, MaterialCost = powderDto.ManualUnitPrice!.Value,
LaborCost = 0, EquipmentCost = 0
};
var pi = _jobItemAssemblyService.CreateJobItem(powderDto, job.Id, companyId, pp, DateTime.UtcNow);
await _unitOfWork.JobItems.AddAsync(pi);
await _unitOfWork.SaveChangesAsync();
allCreateItems.Add(powderDto);
}
}
// Recalculate total from wizard items
var createCosts = await _pricingService.GetOperatingCostsAsync(companyId);
decimal? createOvenRate = null;
@@ -1186,7 +1206,7 @@ public class JobsController : Controller
createOvenRate = createOven.CostPerHour;
}
var totals = await _pricingService.CalculateQuoteTotalsAsync(
dto.JobItems, companyId, dto.CustomerId,
allCreateItems, companyId, dto.CustomerId,
await GetEffectiveTaxPercentAsync(dto.CustomerId, createCosts?.TaxPercent ?? 0m),
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, createOvenRate, dto.OvenBatches > 0 ? dto.OvenBatches : 1, dto.OvenCycleMinutes);
@@ -1400,6 +1420,26 @@ public class JobsController : Controller
}
}
// Option B: auto-add Custom Powder Order item on first save if not already present
var allEditItems = dto.JobItems.ToList();
if (!allEditItems.Any(d => d.IsGenericItem && d.Description?.StartsWith("Custom Powder Order") == true))
{
var powderDto = await BuildCustomPowderOrderDto(allEditItems);
if (powderDto != null)
{
var pp = new QuoteItemPricingResult
{
UnitPrice = powderDto.ManualUnitPrice!.Value, TotalPrice = powderDto.ManualUnitPrice!.Value,
ItemSubtotal = powderDto.ManualUnitPrice!.Value, MaterialCost = powderDto.ManualUnitPrice!.Value,
LaborCost = 0, EquipmentCost = 0
};
var pi = _jobItemAssemblyService.CreateJobItem(powderDto, id, companyId, pp, DateTime.UtcNow);
await _unitOfWork.JobItems.AddAsync(pi);
await _unitOfWork.SaveChangesAsync();
allEditItems.Add(powderDto);
}
}
// Now load and update the job itself
var job = await _unitOfWork.Jobs.GetByIdAsync(id);
if (job == null)
@@ -1653,7 +1693,7 @@ public class JobsController : Controller
}
// Recalculate FinalPrice from wizard items
if (dto.JobItems.Any())
if (allEditItems.Any())
{
var editCosts = await _pricingService.GetOperatingCostsAsync(companyId);
decimal? editOvenRate = null;
@@ -1664,7 +1704,7 @@ public class JobsController : Controller
editOvenRate = editOven.CostPerHour;
}
var totals = await _pricingService.CalculateQuoteTotalsAsync(
dto.JobItems, companyId, dto.CustomerId,
allEditItems, companyId, dto.CustomerId,
await GetEffectiveTaxPercentAsync(dto.CustomerId, editCosts?.TaxPercent ?? 0m),
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, editOvenRate, dto.OvenBatches > 0 ? dto.OvenBatches : 1, dto.OvenCycleMinutes);
job.FinalPrice = totals.Total;
@@ -1858,24 +1898,32 @@ public class JobsController : Controller
{
ViewBag.AiPhotoQuotesEnabled = await _subscriptionService.CanUseAiPhotoQuoteAsync(companyId);
var formulaTemplates = await _unitOfWork.CustomItemTemplates.FindAsync(
t => t.CompanyId == companyId && t.IsActive);
ViewBag.CustomFormulaTemplates = formulaTemplates
.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name)
.Select(t => new
{
id = t.Id,
name = t.Name,
description = t.Description,
outputMode = t.OutputMode,
fieldsJson = t.FieldsJson,
formula = t.Formula,
defaultRate = t.DefaultRate,
rateLabel = t.RateLabel,
diagramImagePath = string.IsNullOrEmpty(t.DiagramImagePath)
? (string?)null
: Url.Action("TemplateDiagram", "CompanySettings", new { templateId = t.Id })
}).ToList();
var allowFormulas = HttpContext.Items["AllowCustomFormulas"] as bool? ?? false;
if (allowFormulas)
{
var formulaTemplates = await _unitOfWork.CustomItemTemplates.FindAsync(
t => t.CompanyId == companyId && t.IsActive);
ViewBag.CustomFormulaTemplates = formulaTemplates
.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name)
.Select(t => new
{
id = t.Id,
name = t.Name,
description = t.Description,
outputMode = t.OutputMode,
fieldsJson = t.FieldsJson,
formula = t.Formula,
defaultRate = t.DefaultRate,
rateLabel = t.RateLabel,
diagramImagePath = string.IsNullOrEmpty(t.DiagramImagePath)
? (string?)null
: Url.Action("TemplateDiagram", "CompanySettings", new { templateId = t.Id })
}).ToList();
}
else
{
ViewBag.CustomFormulaTemplates = new List<object>();
}
await PopulateDropdowns();
await PopulatePrepServicesAsync(companyId);
@@ -3127,6 +3175,26 @@ public class JobsController : Controller
}
}
// Option B: auto-add Custom Powder Order item on first save if not already present
var allUpdateItems = model.JobItems.ToList();
if (!allUpdateItems.Any(d => d.IsGenericItem && d.Description?.StartsWith("Custom Powder Order") == true))
{
var powderDto = await BuildCustomPowderOrderDto(allUpdateItems);
if (powderDto != null)
{
var pp = new QuoteItemPricingResult
{
UnitPrice = powderDto.ManualUnitPrice!.Value, TotalPrice = powderDto.ManualUnitPrice!.Value,
ItemSubtotal = powderDto.ManualUnitPrice!.Value, MaterialCost = powderDto.ManualUnitPrice!.Value,
LaborCost = 0, EquipmentCost = 0
};
var pi = _jobItemAssemblyService.CreateJobItem(powderDto, job.Id, currentUser.CompanyId, pp, DateTime.UtcNow);
await _unitOfWork.JobItems.AddAsync(pi);
await _unitOfWork.SaveChangesAsync();
allUpdateItems.Add(powderDto);
}
}
// Calculate full total (overhead, margins, tax) matching what Details shows
decimal? ovenRateOverride = null;
if (job.OvenCostId.HasValue)
@@ -3137,7 +3205,7 @@ public class JobsController : Controller
}
var updateCosts = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
var totals = await _pricingService.CalculateQuoteTotalsAsync(
model.JobItems, currentUser.CompanyId, job.CustomerId,
allUpdateItems, currentUser.CompanyId, job.CustomerId,
await GetEffectiveTaxPercentAsync(job.CustomerId, updateCosts?.TaxPercent ?? 0m),
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
ovenRateOverride, job.OvenBatches, job.OvenCycleMinutes);
@@ -3341,12 +3409,20 @@ public class JobsController : Controller
ViewBag.UseMetric = useMetric;
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
var formulaTemplates = await _unitOfWork.CustomItemTemplates.FindAsync(t => t.CompanyId == companyId && t.IsActive);
ViewBag.CustomFormulaTemplates = formulaTemplates.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name)
.Select(t => new { id = t.Id, name = t.Name, description = t.Description, outputMode = t.OutputMode,
fieldsJson = t.FieldsJson, formula = t.Formula, defaultRate = t.DefaultRate, rateLabel = t.RateLabel,
diagramImagePath = string.IsNullOrEmpty(t.DiagramImagePath) ? null
: Url.Action("TemplateDiagram", "CompanySettings", new { templateId = t.Id }) }).ToList();
var allowFormulas2 = HttpContext.Items["AllowCustomFormulas"] as bool? ?? false;
if (allowFormulas2)
{
var formulaTemplates = await _unitOfWork.CustomItemTemplates.FindAsync(t => t.CompanyId == companyId && t.IsActive);
ViewBag.CustomFormulaTemplates = formulaTemplates.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name)
.Select(t => new { id = t.Id, name = t.Name, description = t.Description, outputMode = t.OutputMode,
fieldsJson = t.FieldsJson, formula = t.Formula, defaultRate = t.DefaultRate, rateLabel = t.RateLabel,
diagramImagePath = string.IsNullOrEmpty(t.DiagramImagePath) ? null
: Url.Action("TemplateDiagram", "CompanySettings", new { templateId = t.Id }) }).ToList();
}
else
{
ViewBag.CustomFormulaTemplates = new List<object>();
}
}
/// <summary>
@@ -3382,6 +3458,66 @@ public class JobsController : Controller
return companyDefaultRate;
}
/// <summary>
/// Builds a "Custom Powder Order" DTO by aggregating all powder-to-order costs across the
/// submitted items. Returns null when no qualifying coats are present.
///
/// Two coat types qualify:
/// - Custom powder: no InventoryItemId, PowderToOrder &gt; 0, PowderCostPerLb &gt; 0
/// - Incoming powder: InventoryItemId set, inventoryItem.IsIncoming == true, PowderToOrder &gt; 0
/// (PowderCostPerLb is null for incoming powder — cost comes from inventoryItem.UnitCost)
/// </summary>
private async Task<CreateQuoteItemDto?> BuildCustomPowderOrderDto(IEnumerable<CreateQuoteItemDto> itemDtos)
{
var colorNames = new List<string>();
decimal totalCost = 0m;
foreach (var dto in itemDtos)
{
if (dto.Coats == null) continue;
foreach (var coat in dto.Coats)
{
if (!coat.InventoryItemId.HasValue &&
coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0 &&
coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0)
{
// Custom powder: no inventory link, user entered cost per lb manually
totalCost += coat.PowderToOrder.Value * coat.PowderCostPerLb.Value;
if (!string.IsNullOrWhiteSpace(coat.ColorName))
colorNames.Add(coat.ColorName);
}
else if (coat.InventoryItemId.HasValue && coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
{
// Incoming powder: catalog-selected; PowderCostPerLb was cleared after incoming
// inventory item was created, so cost comes from inventoryItem.UnitCost
var invItem = await _unitOfWork.InventoryItems.GetByIdAsync(coat.InventoryItemId.Value);
if (invItem?.IsIncoming == true)
{
totalCost += coat.PowderToOrder.Value * invItem.UnitCost;
var colorName = !string.IsNullOrWhiteSpace(coat.ColorName) ? coat.ColorName : invItem.Name;
if (!string.IsNullOrWhiteSpace(colorName))
colorNames.Add(colorName);
}
}
}
}
if (totalCost <= 0) return null;
var uniqueColors = colorNames.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var description = uniqueColors.Any()
? $"Custom Powder Order ({string.Join(", ", uniqueColors)})"
: "Custom Powder Order";
return new CreateQuoteItemDto
{
Description = description,
Quantity = 1,
IsGenericItem = true,
ManualUnitPrice = totalCost
};
}
private static QuotePricingBreakdownDto BuildPricingSnapshotDto(QuotePricingResult pr) =>
new QuotePricingBreakdownDto
{
@@ -2429,24 +2429,32 @@ public class QuotesController : Controller
var (_, quotePhotoMax) = await _subscriptionService.GetQuotePhotoCountAsync(companyId, 0);
ViewBag.QuotePhotosEnabled = quotePhotoMax != 0; // 0 = feature disabled for this plan
var formulaTemplates = await _unitOfWork.CustomItemTemplates.FindAsync(
t => t.CompanyId == companyId && t.IsActive);
ViewBag.CustomFormulaTemplates = formulaTemplates
.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name)
.Select(t => new
{
id = t.Id,
name = t.Name,
description = t.Description,
outputMode = t.OutputMode,
fieldsJson = t.FieldsJson,
formula = t.Formula,
defaultRate = t.DefaultRate,
rateLabel = t.RateLabel,
diagramImagePath = string.IsNullOrEmpty(t.DiagramImagePath)
? (string?)null
: Url.Action("TemplateDiagram", "CompanySettings", new { templateId = t.Id })
}).ToList();
var allowFormulas = HttpContext.Items["AllowCustomFormulas"] as bool? ?? false;
if (allowFormulas)
{
var formulaTemplates = await _unitOfWork.CustomItemTemplates.FindAsync(
t => t.CompanyId == companyId && t.IsActive);
ViewBag.CustomFormulaTemplates = formulaTemplates
.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name)
.Select(t => new
{
id = t.Id,
name = t.Name,
description = t.Description,
outputMode = t.OutputMode,
fieldsJson = t.FieldsJson,
formula = t.Formula,
defaultRate = t.DefaultRate,
rateLabel = t.RateLabel,
diagramImagePath = string.IsNullOrEmpty(t.DiagramImagePath)
? (string?)null
: Url.Action("TemplateDiagram", "CompanySettings", new { templateId = t.Id })
}).ToList();
}
else
{
ViewBag.CustomFormulaTemplates = new List<object>();
}
// Customers
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
@@ -213,6 +213,12 @@ public static class HelpKnowledgeBase
- Customer tier discount
- Tax (0% for tax-exempt customers)
**Custom Powder Order ordering costs as a separate line item:** When a coat is configured with a manually entered cost per lb (no inventory item selected) or with a powder color that must be ordered (the "Order this color" path), the material cost for that powder is NOT added to the individual item price. Instead, the system auto-generates a separate **"Custom Powder Order"** line item that collects all ordering costs in one place. This lets you add shipping/freight to the total before presenting it to the customer.
- While building the quote, a dashed yellow **Powder Order** preview card appears below the item cards. It shows the calculated material cost with an editable price field. Enter the final amount you want to charge material plus any shipping before saving.
- On the saved quote, the Custom Powder Order appears as its own line item with the color name(s) in its description (e.g. "Custom Powder Order (Gloss Black, Satin Silver)").
- A yellow banner on the Quote Details page reminds you when a Custom Powder Order is present so you don't forget to account for shipping.
- The Custom Powder Order is created only once (on first save). After that, the price is yours to edit the system will not overwrite it on subsequent saves.
**Per-item cost breakdown:** On the Quote Details page, each line item shows a collapsible cost breakdown click the row to expand it and see how material, labor, equipment, complexity, and markup were calculated for that specific item. This is useful for spotting which items are underpriced or where costs are concentrated.
**Inline item editing on quotes:** On the Quote Details page, any unit price, quantity, or item description can be edited in-place by clicking the value directly. Press Enter or click away to save; press Escape to cancel. The pricing summary (subtotal, discount, tax, and total) updates immediately without reloading the page.
@@ -338,6 +344,8 @@ public static class HelpKnowledgeBase
**Logging material usage from a PC (without QR scan):** On the Job Details page, expand the Materials Used section and click **Log Material**. A modal opens where you can: select any inventory item from a dropdown (current stock level shown), choose whether to enter the amount used or the amount remaining (the system calculates usage automatically), pick a reason (Job Usage or Waste/Spillage), and add optional notes. Saves immediately and updates inventory on hand.
**Custom Powder Order ordering costs as a separate line item:** Same behavior as quotes. When a coat uses a custom or incoming powder (manually entered cost per lb, or a color ordered through the "Order this color" path), the material cost is separated from the item price and collected into a single **"Custom Powder Order"** line item. While building the job, a dashed yellow Powder Order preview card appears; edit its price before saving to include shipping. After the first save the price is user-owned and will not be overwritten.
**Blank Work Order:** Print a pre-formatted paper work order to hand to a walk-in customer before creating a digital job record.
- Access: Jobs list page printer icon button "Blank Work Order" in the top-right toolbar. Or navigate directly to /WorkOrder/Blank.
- The PDF opens in a new tab ready to print. It includes: company logo and address, Drop Off Date field, Client Name / Client Phone / Due Date fields, 12-row parts table (Part Description / Color / Quote), Notes box, customizable Terms & Conditions text, and a Customer Signature line.
@@ -1196,6 +1204,26 @@ public static class HelpKnowledgeBase
---
## DATA IMPORT / EXPORT
**Where:** [Tools](/Tools) in the left sidebar under Admin Tools
**What it does:** Import records in bulk from CSV, or export your data for use in other systems (QuickBooks Desktop, QuickBooks Online, Excel/CSV). Use the step-by-step wizard: choose Import or Export choose format select data type upload or download.
**Importing Jobs from CSV:**
Required columns: JobNumber, CustomerEmail (or CustomerName), Status, Priority, ScheduledDate, DueDate, FinalPrice, CustomerPO, SpecialInstructions, Notes.
- **FinalPrice**: a number (e.g. 150.00) or blank for $0. Non-numeric values (e.g. a spreadsheet formula that resolves to "false") are treated as $0 with a warning the row is still imported.
- **CustomerEmail vs CustomerName**: email is tried first; if no match is found and CustomerName is provided, name is tried as a fallback. A warning is added when name-matching is used.
- **Status / Priority**: unrecognised values default to Pending / Normal with a warning.
- **Duplicate job numbers**: rows matching an existing job number are skipped with a warning.
After import, the results page shows a summary of rows imported, skipped, and any warnings or errors per row.
**Importing Customers, Inventory, Quotes, Appointments:** Use the same wizard select CSV format and then choose the record type. Each type has a downloadable template (click "Download Template" in the wizard) showing the expected column headers.
**Exporting data:** Choose Export format (CSV, QuickBooks Desktop .IIF, QuickBooks Online .CSV, Excel) select which data types to include download. All exports respect your company's tenant filter you only export your own data.
---
## AUDIT LOG
**Where:** [Audit Log](/AuditLog) under Admin Tools (CompanyAdmin and above)
@@ -273,6 +273,29 @@
appear as sub-lines under the item on the job details page.
</p>
<h3 class="h6 fw-semibold mt-4 mb-2"><i class="bi bi-truck me-1 text-warning"></i>Custom Powder Order</h3>
<p>
When a coat uses a <strong>custom powder</strong> (manually entered cost per lb, no inventory item
selected) or an <strong>incoming powder</strong> (a color being ordered that is not yet in stock),
the material cost is separated from the item price and collected into a single
<strong>Custom Powder Order</strong> line item. This keeps per-item pricing clean and lets you
add shipping or freight before the customer sees the total.
</p>
<ul class="mb-3">
<li class="mb-2">
While building the job, a dashed yellow <strong>Powder Order</strong> preview card appears
below the item cards. Edit the price to include any shipping before saving.
</li>
<li class="mb-2">
On the saved job, the Custom Powder Order appears as its own line item with the ordered
color name(s) in its description.
</li>
<li>
The Custom Powder Order is created once on first save. The price is user-owned after that
and will not be overwritten by the system on subsequent saves.
</li>
</ul>
<h3 class="h6 fw-semibold mt-4 mb-2">Save to Product Catalog</h3>
<p>
After completing the coatings and prep services steps, <strong>Calculated</strong> and
@@ -170,6 +170,35 @@
prep services &mdash; sandblasting, masking, and/or cleaning &mdash; that will be performed before coating.
</p>
<h3 class="h6 fw-semibold mt-4 mb-2"><i class="bi bi-truck me-1 text-warning"></i>Custom Powder Order</h3>
<p>
When a coat uses a <strong>custom powder</strong> (you enter a cost per lb manually without selecting
an inventory item) or an <strong>incoming powder</strong> (a color you need to order that is not yet
in stock), the powder material cost is <strong>not</strong> added to the individual item price.
Instead, the system auto-generates a separate <strong>Custom Powder Order</strong> line item that
collects all ordering costs in one place &mdash; so you can include shipping and freight before
presenting the quote to the customer.
</p>
<ul class="mb-3">
<li class="mb-2">
While building the quote, a dashed yellow <strong>Powder Order</strong> preview card appears
below the item cards showing the calculated material cost. The price field is editable &mdash;
type the total you want to charge (material&nbsp;+&nbsp;any shipping) before saving.
</li>
<li class="mb-2">
On the saved quote, the Custom Powder Order appears as its own line item with the color
name(s) in the description (e.g. <em>Custom Powder Order (Gloss Black, Satin Silver)</em>).
</li>
<li class="mb-2">
A yellow banner on the Quote Details page reminds you when a Custom Powder Order is present
so you don&rsquo;t forget to confirm the shipping amount.
</li>
<li>
The Custom Powder Order is created <strong>once</strong> on first save. After that the price
is yours &mdash; the system will not overwrite it on subsequent saves.
</li>
</ul>
<h3 class="h6 fw-semibold mt-3 mb-2">Save to Product Catalog</h3>
<p>
After completing the prep services step, Calculated and AI Photo items display one final step:
+23 -1
View File
@@ -216,6 +216,28 @@
<p class="small">Click <strong>Add Item</strong> to get started.</p>
</div>
<div id="itemCardsContainer"></div>
<div id="customPowderOrderPreview" class="d-none mt-2">
<div class="quote-item-card border-warning" style="border-style:dashed!important;background:var(--bs-warning-bg-subtle,#fff8e1);">
<div class="d-flex align-items-start gap-2">
<div class="flex-grow-1">
<div class="d-flex align-items-center flex-wrap gap-1 mb-1">
<span class="badge bg-warning text-dark item-badge"><i class="bi bi-truck me-1"></i>Powder Order</span>
<span class="fw-semibold" id="customPowderOrderPreviewDesc">Custom Powder Order</span>
</div>
<div class="text-muted small"><span id="customPowderOrderPreviewPrice"></span> &mdash; edit total to include shipping</div>
</div>
<div class="text-center flex-shrink-0" style="min-width:45px;">1</div>
<div class="flex-shrink-0" style="min-width:90px;">
<input type="number" id="customPowderOrderPriceInput"
class="form-control form-control-sm text-end"
min="0" step="0.01" placeholder="0.00"
title="Enter total including any shipping"
oninput="onCustomPowderPriceEdit(this.value)">
</div>
<div style="min-width:66px;"></div>
</div>
</div>
</div>
<div id="hiddenFieldsContainer"></div>
<div id="aiPhotoTempIdsContainer"></div>
</div>
@@ -411,7 +433,7 @@
sequence = c.Sequence,
inventoryItemId = c.InventoryItemId,
colorName = c.ColorName,
vendorId = c.VendorId,
supplierId = c.VendorId,
colorCode = c.ColorCode,
finish = c.Finish,
coverageSqFtPerLb = c.CoverageSqFtPerLb,
@@ -326,6 +326,13 @@
<i class="bi bi-plus-circle me-1"></i>Add Item
</button>
</div>
@if (Model.Items.Any(i => i.Description != null && i.Description.StartsWith("Custom Powder Order")))
{
<div class="alert alert-warning alert-permanent mx-3 mt-3 mb-0" role="alert">
<i class="bi bi-truck me-2"></i><strong>Custom Powder Order</strong> &mdash;
The line item below shows material cost only. Remember to add shipping before invoicing this job.
</div>
}
@if (Model.Items.Any())
{
var allItems = Model.Items.ToList();
+23 -1
View File
@@ -185,6 +185,28 @@
<p class="small">Click <strong>Add Item</strong> to get started.</p>
</div>
<div id="itemCardsContainer"></div>
<div id="customPowderOrderPreview" class="d-none mt-2">
<div class="quote-item-card border-warning" style="border-style:dashed!important;background:var(--bs-warning-bg-subtle,#fff8e1);">
<div class="d-flex align-items-start gap-2">
<div class="flex-grow-1">
<div class="d-flex align-items-center flex-wrap gap-1 mb-1">
<span class="badge bg-warning text-dark item-badge"><i class="bi bi-truck me-1"></i>Powder Order</span>
<span class="fw-semibold" id="customPowderOrderPreviewDesc">Custom Powder Order</span>
</div>
<div class="text-muted small"><span id="customPowderOrderPreviewPrice"></span> &mdash; edit total to include shipping</div>
</div>
<div class="text-center flex-shrink-0" style="min-width:45px;">1</div>
<div class="flex-shrink-0" style="min-width:90px;">
<input type="number" id="customPowderOrderPriceInput"
class="form-control form-control-sm text-end"
min="0" step="0.01" placeholder="0.00"
title="Enter total including any shipping"
oninput="onCustomPowderPriceEdit(this.value)">
</div>
<div style="min-width:66px;"></div>
</div>
</div>
</div>
<div id="hiddenFieldsContainer"></div>
<div id="aiPhotoTempIdsContainer"></div>
</div>
@@ -396,7 +418,7 @@
sequence = c.Sequence,
inventoryItemId = c.InventoryItemId,
colorName = c.ColorName,
vendorId = c.VendorId,
supplierId = c.VendorId,
colorCode = c.ColorCode,
finish = c.Finish,
coverageSqFtPerLb = c.CoverageSqFtPerLb,
@@ -50,6 +50,28 @@
<p class="small">Click <strong>Add Item</strong> to get started.</p>
</div>
<div id="itemCardsContainer"></div>
<div id="customPowderOrderPreview" class="d-none mt-2">
<div class="quote-item-card border-warning" style="border-style:dashed!important;background:var(--bs-warning-bg-subtle,#fff8e1);">
<div class="d-flex align-items-start gap-2">
<div class="flex-grow-1">
<div class="d-flex align-items-center flex-wrap gap-1 mb-1">
<span class="badge bg-warning text-dark item-badge"><i class="bi bi-truck me-1"></i>Powder Order</span>
<span class="fw-semibold" id="customPowderOrderPreviewDesc">Custom Powder Order</span>
</div>
<div class="text-muted small"><span id="customPowderOrderPreviewPrice"></span> &mdash; edit total to include shipping</div>
</div>
<div class="text-center flex-shrink-0" style="min-width:45px;">1</div>
<div class="flex-shrink-0" style="min-width:90px;">
<input type="number" id="customPowderOrderPriceInput"
class="form-control form-control-sm text-end"
min="0" step="0.01" placeholder="0.00"
title="Enter total including any shipping"
oninput="onCustomPowderPriceEdit(this.value)">
</div>
<div style="min-width:66px;"></div>
</div>
</div>
</div>
<!-- Hidden fields written by wizard JS -->
<div id="hiddenFieldsContainer"></div>
@@ -149,7 +171,7 @@
sequence = c.Sequence,
inventoryItemId = c.InventoryItemId,
colorName = c.ColorName,
vendorId = c.VendorId,
supplierId = c.VendorId,
colorCode = c.ColorCode,
finish = c.Finish,
coverageSqFtPerLb = c.CoverageSqFtPerLb,
@@ -268,6 +268,28 @@
<p class="small">Click <strong>Add Item</strong> to get started.</p>
</div>
<div id="itemCardsContainer"></div>
<div id="customPowderOrderPreview" class="d-none mt-2">
<div class="quote-item-card border-warning" style="border-style:dashed!important;background:var(--bs-warning-bg-subtle,#fff8e1);">
<div class="d-flex align-items-start gap-2">
<div class="flex-grow-1">
<div class="d-flex align-items-center flex-wrap gap-1 mb-1">
<span class="badge bg-warning text-dark item-badge"><i class="bi bi-truck me-1"></i>Powder Order</span>
<span class="fw-semibold" id="customPowderOrderPreviewDesc">Custom Powder Order</span>
</div>
<div class="text-muted small"><span id="customPowderOrderPreviewPrice"></span> &mdash; edit total to include shipping</div>
</div>
<div class="text-center flex-shrink-0" style="min-width:45px;">1</div>
<div class="flex-shrink-0" style="min-width:90px;">
<input type="number" id="customPowderOrderPriceInput"
class="form-control form-control-sm text-end"
min="0" step="0.01" placeholder="0.00"
title="Enter total including any shipping"
oninput="onCustomPowderPriceEdit(this.value)">
</div>
<div style="min-width:66px;"></div>
</div>
</div>
</div>
<!-- Hidden fields written by wizard JS -->
<div id="hiddenFieldsContainer"></div>
@@ -477,7 +499,7 @@
sequence = c.Sequence,
inventoryItemId = c.InventoryItemId,
colorName = c.ColorName,
vendorId = c.VendorId,
supplierId = c.VendorId,
colorCode = c.ColorCode,
finish = c.Finish,
coverageSqFtPerLb = c.CoverageSqFtPerLb,
@@ -251,6 +251,13 @@
</h5>
</div>
<div class="card-body">
@if (Model.QuoteItems != null && Model.QuoteItems.Any(i => i.Description != null && i.Description.StartsWith("Custom Powder Order")))
{
<div class="alert alert-warning alert-permanent mb-3" role="alert">
<i class="bi bi-truck me-2"></i><strong>Custom Powder Order</strong> &mdash;
The line item below shows material cost only. Add shipping charges before sending this quote to the customer.
</div>
}
@if (Model.QuoteItems != null && Model.QuoteItems.Any())
{
var catalogItems = Model.QuoteItems.Where(i => i.CatalogItemId.HasValue).ToList();
+24 -1
View File
@@ -231,6 +231,28 @@
<p class="small">Click <strong>Add Item</strong> to get started.</p>
</div>
<div id="itemCardsContainer"></div>
<div id="customPowderOrderPreview" class="d-none mt-2">
<div class="quote-item-card border-warning" style="border-style:dashed!important;background:var(--bs-warning-bg-subtle,#fff8e1);">
<div class="d-flex align-items-start gap-2">
<div class="flex-grow-1">
<div class="d-flex align-items-center flex-wrap gap-1 mb-1">
<span class="badge bg-warning text-dark item-badge"><i class="bi bi-truck me-1"></i>Powder Order</span>
<span class="fw-semibold" id="customPowderOrderPreviewDesc">Custom Powder Order</span>
</div>
<div class="text-muted small"><span id="customPowderOrderPreviewPrice"></span> &mdash; edit total to include shipping</div>
</div>
<div class="text-center flex-shrink-0" style="min-width:45px;">1</div>
<div class="flex-shrink-0" style="min-width:90px;">
<input type="number" id="customPowderOrderPriceInput"
class="form-control form-control-sm text-end"
min="0" step="0.01" placeholder="0.00"
title="Enter total including any shipping"
oninput="onCustomPowderPriceEdit(this.value)">
</div>
<div style="min-width:66px;"></div>
</div>
</div>
</div>
<!-- Hidden fields written by wizard JS -->
<div id="hiddenFieldsContainer"></div>
@@ -500,6 +522,7 @@
powderCostOverride = item.PowderCostOverride,
isGenericItem = item.IsGenericItem,
isLaborItem = item.IsLaborItem,
isSalesItem = item.IsSalesItem,
isAiItem = item.IsAiItem,
isCustomFormulaItem = item.IsCustomFormulaItem,
customItemTemplateId = item.CustomItemTemplateId,
@@ -521,7 +544,7 @@
sequence = c.Sequence,
inventoryItemId = c.InventoryItemId,
colorName = c.ColorName,
vendorId = c.VendorId,
supplierId = c.VendorId,
colorCode = c.ColorCode,
finish = c.Finish,
coverageSqFtPerLb = c.CoverageSqFtPerLb,
@@ -14,6 +14,13 @@
let quoteItems = []; // Array of item objects matching CreateQuoteItemDto shape
// Custom Powder Order preview state — tracks the auto-calculated material cost and any
// user override (e.g. adding shipping). Reset when the server says no powder is needed.
let _customPowderAutoAmount = 0; // last server-calculated material cost
let _customPowderUserAmount = null; // null = use auto; number = user override
let _customPowderPreviewLabel = 'Custom Powder Order';
let _pricingPayloadHadUserPowderItem = false; // set just before each pricing fetch
const wz = { // Wizard state
step: 1,
editIndex: -1, // -1 = new item; >= 0 = editing
@@ -903,7 +910,13 @@ function renderFormulaFields() {
</div>
</div>
<div id="wz_formula_fields">${fieldsHtml}</div>
<div id="err_formulaCalc" class="text-danger small mt-1 d-none">Please calculate the formula first.</div>`;
<div id="err_formulaCalc" class="text-danger small mt-1 d-none">Please calculate the formula first.</div>
<div class="form-text mt-2 text-muted" style="font-size:0.75rem;">
<i class="bi bi-info-circle me-1"></i>Shop rates automatically applied:
<code class="ms-1">standard_labor_rate</code>
<code class="ms-1">additional_coat_labor_pct</code>
<code class="ms-1">markup_pct</code>
</div>`;
}
window.wzFormulaTemplateChanged = function () {
@@ -2942,6 +2955,8 @@ function buildCardHtml(item, i) {
? `${item.quantity} hr${item.quantity !== 1 ? 's' : ''}`
: item.isSalesItem
? `$${fmtNum(item.manualUnitPrice)} × ${item.quantity}${item.sku ? ` · <span class="text-muted small">${escHtml(item.sku)}</span>` : ''}`
: item.isCustomFormulaItem && item.manualUnitPrice != null
? `Total: $${fmtNum(item.manualUnitPrice)}${item.quantity > 1 ? ` · Qty: ${item.quantity}` : ''}`
: item.surfaceAreaSqFt
? `${item.quantity} × ${fmtNum(item.surfaceAreaSqFt)} ${pageMeta.areaUnit || 'sq ft'}`
: `Qty: ${item.quantity}`;
@@ -3095,6 +3110,27 @@ function writeHiddenFields() {
});
});
// When a Custom Powder Order is pending (preview visible), include it as a submitted item
// so the server uses this price (user's override or auto amount) and skips auto-creation.
if (_customPowderAutoAmount > 0) {
const amount = _customPowderUserAmount ?? _customPowderAutoAmount;
const n = quoteItems.length;
const prefix = pageMeta.itemsFieldPrefix || 'QuoteItems';
const p = `${prefix}[${n}]`;
fields.push(h(p + '.Description', _customPowderPreviewLabel));
fields.push(h(p + '.Quantity', 1));
fields.push(h(p + '.IsGenericItem', 'true'));
fields.push(h(p + '.ManualUnitPrice', amount.toFixed(2)));
fields.push(h(p + '.SurfaceAreaSqFt', 0));
fields.push(h(p + '.EstimatedMinutes', 0));
fields.push(h(p + '.IsLaborItem', 'false'));
fields.push(h(p + '.IsSalesItem', 'false'));
fields.push(h(p + '.RequiresSandblasting', 'false'));
fields.push(h(p + '.RequiresMasking', 'false'));
fields.push(h(p + '.IncludePrepCost', 'false'));
fields.push(h(p + '.Complexity', 'Simple'));
}
container.innerHTML = fields.join('');
// Write all AI photo tempIds as top-level form fields for photo promotion on save
@@ -3140,8 +3176,24 @@ async function runAutoPricing() {
const ovenBatches = parseInt(document.getElementById('OvenBatches')?.value) || 1;
const ovenCycleMinutes = parseInt(document.getElementById('OvenCycleMinutes')?.value) || null;
// When the user has overridden the powder order price, inject it as a virtual
// Generic item so the server treats it as an existing Custom Powder Order and
// prices the total at the user's amount (including any added shipping).
const pricingItems = [...quoteItems];
_pricingPayloadHadUserPowderItem = false;
if (_customPowderAutoAmount > 0 && _customPowderUserAmount !== null) {
pricingItems.push({
description: _customPowderPreviewLabel,
isGenericItem: true,
manualUnitPrice: _customPowderUserAmount,
quantity: 1,
coats: []
});
_pricingPayloadHadUserPowderItem = true;
}
const payload = {
items: quoteItems,
items: pricingItems,
customerId,
taxPercent,
discountType,
@@ -3178,6 +3230,36 @@ function updatePricingDisplay(r) {
const show = (id, visible) => document.getElementById(id)?.classList.toggle('d-none', !visible);
const setText = (id, text) => { const el = document.getElementById(id); if (el) el.textContent = text; };
// Custom Powder Order preview — server returns amount > 0 only when powder must be ordered
// and no existing Custom Powder Order item is already in the submitted list.
if (r.customPowderOrderAmount > 0) {
_customPowderAutoAmount = r.customPowderOrderAmount;
const colors = r.customPowderOrderColors || [];
_customPowderPreviewLabel = colors.length > 0
? `Custom Powder Order (${colors.join(', ')})`
: 'Custom Powder Order';
} else if (!_pricingPayloadHadUserPowderItem) {
// Server found no powder and we didn't inject a user item — truly none needed
_customPowderAutoAmount = 0;
_customPowderUserAmount = null;
}
const hasPowderPreview = _customPowderAutoAmount > 0;
const preview = document.getElementById('customPowderOrderPreview');
if (preview) {
preview.classList.toggle('d-none', !hasPowderPreview);
if (hasPowderPreview) {
const descEl = document.getElementById('customPowderOrderPreviewDesc');
if (descEl) descEl.textContent = _customPowderPreviewLabel;
const priceEl = document.getElementById('customPowderOrderPreviewPrice');
if (priceEl) priceEl.textContent = 'Material: $' + fmtNum(_customPowderAutoAmount);
// Only reset the input when user hasn't overridden
if (_customPowderUserAmount === null) {
const input = document.getElementById('customPowderOrderPriceInput');
if (input) input.value = _customPowderAutoAmount.toFixed(2);
}
}
}
document.getElementById('pricingPlaceholder')?.classList.add('d-none');
show('itemsSubtotalRow', true);
setText('itemsSubtotalDisplay', '$' + fmtNum(r.itemsSubtotal));
@@ -3242,6 +3324,17 @@ function resetPricingDisplay() {
document.getElementById('pricingPlaceholder')?.classList.remove('d-none');
['itemsSubtotalRow','ovenBatchCostRow','pricingTierDiscountRow','quoteDiscountRow','rushFeeRow',
'shopSuppliesRow','subtotalRow','taxRow','pricingDivider','totalRow'].forEach(hide);
document.getElementById('customPowderOrderPreview')?.classList.add('d-none');
_customPowderAutoAmount = 0;
_customPowderUserAmount = null;
}
/// Called when the user edits the Custom Powder Order price input.
/// Stores the override and re-runs pricing so the total reflects the new amount.
function onCustomPowderPriceEdit(val) {
const v = parseFloat(val);
_customPowderUserAmount = (!val || isNaN(v) || v <= 0) ? null : v;
scheduleAutoPricing();
}
// ─── Wizard UI helpers ────────────────────────────────────────────────────────
@@ -327,11 +327,13 @@ public class PricingCalculationServiceTests
var result = await service.CalculateQuoteItemPriceAsync(item, companyId: 1);
Assert.Equal(10m, result.MaterialCost);
// Custom powder material ($10) is excluded from the item price — it moves to the
// auto-generated "Custom Powder Order" line item so users can add shipping.
Assert.Equal(0m, result.MaterialCost);
Assert.Equal(30m, result.LaborCost);
Assert.Equal(0m, result.EquipmentCost);
Assert.Equal(70m, result.UnitPrice);
Assert.Equal(140m, result.TotalPrice);
Assert.Equal(65m, result.UnitPrice); // catalog $50 + prep $15/unit
Assert.Equal(130m, result.TotalPrice);
}
[Fact]