From a7ad0e1de88bd3f551ab97987df2af195e7fe92b Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Mon, 25 May 2026 23:37:46 -0400 Subject: [PATCH] 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 --- .../DTOs/Quote/QuoteDtos.cs | 5 + .../Mappings/QuoteProfile.cs | 3 + .../Services/PricingCalculationService.cs | 81 +++++++- .../Services/QuotePricingAssemblyService.cs | 88 +++++++- .../Services/CsvImportService.cs | 66 ++++-- .../Controllers/JobsController.cs | 192 +++++++++++++++--- .../Controllers/QuotesController.cs | 44 ++-- .../Helpers/HelpKnowledgeBase.cs | 28 +++ src/PowderCoating.Web/Views/Help/Jobs.cshtml | 23 +++ .../Views/Help/Quotes.cshtml | 29 +++ .../Views/Jobs/Create.cshtml | 24 ++- .../Views/Jobs/Details.cshtml | 7 + src/PowderCoating.Web/Views/Jobs/Edit.cshtml | 24 ++- .../Views/Jobs/EditItems.cshtml | 24 ++- .../Views/Quotes/Create.cshtml | 24 ++- .../Views/Quotes/Details.cshtml | 7 + .../Views/Quotes/Edit.cshtml | 25 ++- .../wwwroot/js/item-wizard.js | 97 ++++++++- .../PricingCalculationServiceTests.cs | 8 +- 19 files changed, 721 insertions(+), 78 deletions(-) diff --git a/src/PowderCoating.Application/DTOs/Quote/QuoteDtos.cs b/src/PowderCoating.Application/DTOs/Quote/QuoteDtos.cs index 4d3827e..0bb9c48 100644 --- a/src/PowderCoating.Application/DTOs/Quote/QuoteDtos.cs +++ b/src/PowderCoating.Application/DTOs/Quote/QuoteDtos.cs @@ -884,4 +884,9 @@ public class QuotePricingResult // Per-item results (same order as input items) public List 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 CustomPowderOrderColors { get; set; } = new(); } diff --git a/src/PowderCoating.Application/Mappings/QuoteProfile.cs b/src/PowderCoating.Application/Mappings/QuoteProfile.cs index b0afae4..49788ce 100644 --- a/src/PowderCoating.Application/Mappings/QuoteProfile.cs +++ b/src/PowderCoating.Application/Mappings/QuoteProfile.cs @@ -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 → List with different element types. CreateMap() + .ForMember(dest => dest.Coats, opt => opt.MapFrom(src => src.Coats)) .ForMember(dest => dest.PrepServices, opt => opt.MapFrom(src => src.PrepServices)); // ============================================================================ diff --git a/src/PowderCoating.Application/Services/PricingCalculationService.cs b/src/PowderCoating.Application/Services/PricingCalculationService.cs index c4a3434..5fbc1c6 100644 --- a/src/PowderCoating.Application/Services/PricingCalculationService.cs +++ b/src/PowderCoating.Application/Services/PricingCalculationService.cs @@ -220,6 +220,16 @@ public class PricingCalculationService : IPricingCalculationService }; } + /// + /// 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. + /// + private static bool IsCustomPowderCoat(CreateQuoteItemCoatDto coat) => + !coat.InventoryItemId.HasValue && + coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0 && + coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0; + /// /// 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(); + 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() }; } } diff --git a/src/PowderCoating.Application/Services/QuotePricingAssemblyService.cs b/src/PowderCoating.Application/Services/QuotePricingAssemblyService.cs index 84ee1b8..e7b4449 100644 --- a/src/PowderCoating.Application/Services/QuotePricingAssemblyService.cs +++ b/src/PowderCoating.Application/Services/QuotePricingAssemblyService.cs @@ -90,8 +90,9 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService { ArgumentNullException.ThrowIfNull(itemDtos); + var dtoList = itemDtos.ToList(); var items = new List(); - 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; } } + + /// + /// 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 + /// 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 > 0 + /// - Incoming powder: InventoryItemId set but inventoryItem.IsIncoming == true + /// (auto-created by ; PowderCostPerLb cleared + /// after creation, so cost comes from inventoryItem.UnitCost instead) + /// + private async Task BuildCustomPowderOrderItemAsync( + IReadOnlyList itemDtos, int quoteId, int companyId, DateTime createdAtUtc) + { + var colorNames = new List(); + 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 = [] + }; + } } diff --git a/src/PowderCoating.Infrastructure/Services/CsvImportService.cs b/src/PowderCoating.Infrastructure/Services/CsvImportService.cs index 6fa6e37..7b8bde7 100644 --- a/src/PowderCoating.Infrastructure/Services/CsvImportService.cs +++ b/src/PowderCoating.Infrastructure/Services/CsvImportService.cs @@ -1268,24 +1268,22 @@ public class CsvImportService : ICsvImportService MissingFieldFound = null }); - var records = csv.GetRecords().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(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(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() + ?? 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(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; } } + + /// + /// 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. + /// + 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(); + } } diff --git a/src/PowderCoating.Web/Controllers/JobsController.cs b/src/PowderCoating.Web/Controllers/JobsController.cs index aba9548..841fd02 100644 --- a/src/PowderCoating.Web/Controllers/JobsController.cs +++ b/src/PowderCoating.Web/Controllers/JobsController.cs @@ -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(); + } 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(); + } } /// @@ -3382,6 +3458,66 @@ public class JobsController : Controller return companyDefaultRate; } + /// + /// 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 > 0, PowderCostPerLb > 0 + /// - Incoming powder: InventoryItemId set, inventoryItem.IsIncoming == true, PowderToOrder > 0 + /// (PowderCostPerLb is null for incoming powder — cost comes from inventoryItem.UnitCost) + /// + private async Task BuildCustomPowderOrderDto(IEnumerable itemDtos) + { + var colorNames = new List(); + 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 { diff --git a/src/PowderCoating.Web/Controllers/QuotesController.cs b/src/PowderCoating.Web/Controllers/QuotesController.cs index 0adc0d8..bed043a 100644 --- a/src/PowderCoating.Web/Controllers/QuotesController.cs +++ b/src/PowderCoating.Web/Controllers/QuotesController.cs @@ -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(); + } // Customers var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId); diff --git a/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs b/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs index 2dc8421..b6af2ed 100644 --- a/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs +++ b/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs @@ -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) diff --git a/src/PowderCoating.Web/Views/Help/Jobs.cshtml b/src/PowderCoating.Web/Views/Help/Jobs.cshtml index 5da00db..8df70f0 100644 --- a/src/PowderCoating.Web/Views/Help/Jobs.cshtml +++ b/src/PowderCoating.Web/Views/Help/Jobs.cshtml @@ -273,6 +273,29 @@ appear as sub-lines under the item on the job details page.

+

Custom Powder Order

+

+ When a coat uses a custom powder (manually entered cost per lb, no inventory item + selected) or an incoming powder (a color being ordered that is not yet in stock), + the material cost is separated from the item price and collected into a single + Custom Powder Order line item. This keeps per-item pricing clean and lets you + add shipping or freight before the customer sees the total. +

+
    +
  • + While building the job, a dashed yellow Powder Order preview card appears + below the item cards. Edit the price to include any shipping before saving. +
  • +
  • + On the saved job, the Custom Powder Order appears as its own line item with the ordered + color name(s) in its description. +
  • +
  • + 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. +
  • +
+

Save to Product Catalog

After completing the coatings and prep services steps, Calculated and diff --git a/src/PowderCoating.Web/Views/Help/Quotes.cshtml b/src/PowderCoating.Web/Views/Help/Quotes.cshtml index 7dcfc54..d534b79 100644 --- a/src/PowderCoating.Web/Views/Help/Quotes.cshtml +++ b/src/PowderCoating.Web/Views/Help/Quotes.cshtml @@ -170,6 +170,35 @@ prep services — sandblasting, masking, and/or cleaning — that will be performed before coating.

+

Custom Powder Order

+

+ When a coat uses a custom powder (you enter a cost per lb manually without selecting + an inventory item) or an incoming powder (a color you need to order that is not yet + in stock), the powder material cost 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 — so you can include shipping and freight before + presenting the quote to the customer. +

+
    +
  • + While building the quote, a dashed yellow Powder Order preview card appears + below the item cards showing the calculated material cost. The price field is editable — + type the total you want to charge (material + any shipping) before saving. +
  • +
  • + On the saved quote, the Custom Powder Order appears as its own line item with the color + name(s) in the 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 confirm the shipping amount. +
  • +
  • + The Custom Powder Order is created once on first save. After that the price + is yours — the system will not overwrite it on subsequent saves. +
  • +
+

Save to Product Catalog

After completing the prep services step, Calculated and AI Photo items display one final step: diff --git a/src/PowderCoating.Web/Views/Jobs/Create.cshtml b/src/PowderCoating.Web/Views/Jobs/Create.cshtml index 772d2a9..f1bcdfd 100644 --- a/src/PowderCoating.Web/Views/Jobs/Create.cshtml +++ b/src/PowderCoating.Web/Views/Jobs/Create.cshtml @@ -216,6 +216,28 @@

Click Add Item to get started.

+
+
+
+
+
+ Powder Order + Custom Powder Order +
+
— edit total to include shipping
+
+
1
+
+ +
+
+
+
+
@@ -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, diff --git a/src/PowderCoating.Web/Views/Jobs/Details.cshtml b/src/PowderCoating.Web/Views/Jobs/Details.cshtml index 9e498ae..423a771 100644 --- a/src/PowderCoating.Web/Views/Jobs/Details.cshtml +++ b/src/PowderCoating.Web/Views/Jobs/Details.cshtml @@ -326,6 +326,13 @@ Add Item + @if (Model.Items.Any(i => i.Description != null && i.Description.StartsWith("Custom Powder Order"))) + { + + } @if (Model.Items.Any()) { var allItems = Model.Items.ToList(); diff --git a/src/PowderCoating.Web/Views/Jobs/Edit.cshtml b/src/PowderCoating.Web/Views/Jobs/Edit.cshtml index e86eca2..bca3d70 100644 --- a/src/PowderCoating.Web/Views/Jobs/Edit.cshtml +++ b/src/PowderCoating.Web/Views/Jobs/Edit.cshtml @@ -185,6 +185,28 @@

Click Add Item to get started.

+
+
+
+
+
+ Powder Order + Custom Powder Order +
+
— edit total to include shipping
+
+
1
+
+ +
+
+
+
+
@@ -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, diff --git a/src/PowderCoating.Web/Views/Jobs/EditItems.cshtml b/src/PowderCoating.Web/Views/Jobs/EditItems.cshtml index ae37303..dba6d06 100644 --- a/src/PowderCoating.Web/Views/Jobs/EditItems.cshtml +++ b/src/PowderCoating.Web/Views/Jobs/EditItems.cshtml @@ -50,6 +50,28 @@

Click Add Item to get started.

+
+
+
+
+
+ Powder Order + Custom Powder Order +
+
— edit total to include shipping
+
+
1
+
+ +
+
+
+
+
@@ -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, diff --git a/src/PowderCoating.Web/Views/Quotes/Create.cshtml b/src/PowderCoating.Web/Views/Quotes/Create.cshtml index ebd9a34..c96e881 100644 --- a/src/PowderCoating.Web/Views/Quotes/Create.cshtml +++ b/src/PowderCoating.Web/Views/Quotes/Create.cshtml @@ -268,6 +268,28 @@

Click Add Item to get started.

+
+
+
+
+
+ Powder Order + Custom Powder Order +
+
— edit total to include shipping
+
+
1
+
+ +
+
+
+
+
@@ -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, diff --git a/src/PowderCoating.Web/Views/Quotes/Details.cshtml b/src/PowderCoating.Web/Views/Quotes/Details.cshtml index 88f5c60..51bea72 100644 --- a/src/PowderCoating.Web/Views/Quotes/Details.cshtml +++ b/src/PowderCoating.Web/Views/Quotes/Details.cshtml @@ -251,6 +251,13 @@
+ @if (Model.QuoteItems != null && Model.QuoteItems.Any(i => i.Description != null && i.Description.StartsWith("Custom Powder Order"))) + { + + } @if (Model.QuoteItems != null && Model.QuoteItems.Any()) { var catalogItems = Model.QuoteItems.Where(i => i.CatalogItemId.HasValue).ToList(); diff --git a/src/PowderCoating.Web/Views/Quotes/Edit.cshtml b/src/PowderCoating.Web/Views/Quotes/Edit.cshtml index 789802d..0c853ef 100644 --- a/src/PowderCoating.Web/Views/Quotes/Edit.cshtml +++ b/src/PowderCoating.Web/Views/Quotes/Edit.cshtml @@ -231,6 +231,28 @@

Click Add Item to get started.

+
+
+
+
+
+ Powder Order + Custom Powder Order +
+
— edit total to include shipping
+
+
1
+
+ +
+
+
+
+
@@ -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, diff --git a/src/PowderCoating.Web/wwwroot/js/item-wizard.js b/src/PowderCoating.Web/wwwroot/js/item-wizard.js index ff5ea36..b7b266e 100644 --- a/src/PowderCoating.Web/wwwroot/js/item-wizard.js +++ b/src/PowderCoating.Web/wwwroot/js/item-wizard.js @@ -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() {
${fieldsHtml}
-
Please calculate the formula first.
`; +
Please calculate the formula first.
+
+ Shop rates automatically applied: + standard_labor_rate + additional_coat_labor_pct + markup_pct +
`; } 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 ? ` · ${escHtml(item.sku)}` : ''}` + : 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 ──────────────────────────────────────────────────────── diff --git a/tests/PowderCoating.UnitTests/PricingCalculationServiceTests.cs b/tests/PowderCoating.UnitTests/PricingCalculationServiceTests.cs index 86fcea1..31aa08b 100644 --- a/tests/PowderCoating.UnitTests/PricingCalculationServiceTests.cs +++ b/tests/PowderCoating.UnitTests/PricingCalculationServiceTests.cs @@ -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]