From 0d980e651aa9241327164f94a84778ff3e9697c3 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Fri, 8 May 2026 20:47:44 -0400 Subject: [PATCH] Add pricing breakdown and powder pre-fill to Job Details; surface voided invoice history - Job Details: collapsible internal pricing breakdown card mirrors quote details breakdown (items subtotal, shop supplies, discount, rush fee, tax, total) - Job Details: voided invoice history section shows previous invoices instead of hiding them - Complete Job modal: pre-fills powder usage from QR-scanned / manually logged entries so staff don't double-log; consumes pre-logged credit per InventoryItemId before deducting net delta - JobProfile: map ShopSuppliesAmount, ShopSuppliesPercent, IsRushJob, DiscountType/Value/Reason so the pricing breakdown has the data it needs Co-Authored-By: Claude Sonnet 4.6 --- .../Mappings/JobProfile.cs | 1 + .../Controllers/JobsController.cs | 210 ++++++++++--- .../Views/Jobs/Details.cshtml | 280 +++++++++++++++++- src/PowderCoating.Web/Views/Jobs/Edit.cshtml | 2 +- .../Views/Jobs/_CompleteJobModal.cshtml | 20 +- 5 files changed, 454 insertions(+), 59 deletions(-) diff --git a/src/PowderCoating.Application/Mappings/JobProfile.cs b/src/PowderCoating.Application/Mappings/JobProfile.cs index c41e47f..b4415a0 100644 --- a/src/PowderCoating.Application/Mappings/JobProfile.cs +++ b/src/PowderCoating.Application/Mappings/JobProfile.cs @@ -52,6 +52,7 @@ public class JobProfile : Profile .ForMember(dest => dest.PrepServiceIds, opt => opt.MapFrom(src => src.JobPrepServices.Select(jps => jps.PrepServiceId).ToList())) .ForMember(dest => dest.TimeEntries, opt => opt.MapFrom(src => src.TimeEntries)) + .ForMember(dest => dest.DiscountType, opt => opt.MapFrom(src => src.DiscountType.ToString())) .ForMember(dest => dest.IsReworkJob, opt => opt.MapFrom(src => src.IsReworkJob)) .ForMember(dest => dest.OriginalJobId, opt => opt.MapFrom(src => src.OriginalJobId)) .ForMember(dest => dest.OriginalJobNumber, diff --git a/src/PowderCoating.Web/Controllers/JobsController.cs b/src/PowderCoating.Web/Controllers/JobsController.cs index 2ec0741..b1e4ad9 100644 --- a/src/PowderCoating.Web/Controllers/JobsController.cs +++ b/src/PowderCoating.Web/Controllers/JobsController.cs @@ -395,11 +395,17 @@ public class JobsController : Controller ViewBag.UseMetric = useMetric; ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric); - // Check if an invoice exists for this job - var jobInvoice = await _unitOfWork.Invoices.GetForJobAsync(id.Value); + // Separate active invoice from voided history for this job + var allJobInvoices = await _unitOfWork.Invoices.FindAsync(i => i.JobId == id.Value); + var jobInvoice = allJobInvoices.FirstOrDefault(i => i.Status != Core.Enums.InvoiceStatus.Voided); + var voidedInvoices = allJobInvoices + .Where(i => i.Status == Core.Enums.InvoiceStatus.Voided) + .Select(i => new { i.Id, i.InvoiceNumber }) + .ToList(); ViewBag.JobInvoiceId = jobInvoice?.Id; ViewBag.JobInvoiceNumber = jobInvoice?.InvoiceNumber; ViewBag.JobInvoiceStatus = jobInvoice?.Status; + ViewBag.JobVoidedInvoices = voidedInvoices; // Workers dropdown for inline assignment await PopulateWorkersDropdown(); @@ -410,11 +416,79 @@ public class JobsController : Controller .OrderBy(u => u.FirstName).ThenBy(u => u.LastName) .ToListAsync(); ViewBag.ShopWorkers = companyUsers.Select(u => new { Id = u.Id, Name = u.FullName }).ToList(); + ViewBag.CurrentUserId = _userManager.GetUserId(User); // Populate Edit Items wizard data (inline modal on Details page) var wizardCosts = await _pricingService.GetOperatingCostsAsync(job.CompanyId); await PopulateJobItemDropDownsAsync(job.CompanyId, wizardCosts?.OvenOperatingCostPerHour ?? 45m); ViewBag.WizardTaxPercent = wizardCosts?.TaxPercent ?? 0m; + + // Internal pricing breakdown (not printed — mirrors quote details breakdown) + var breakdownItems = job.JobItems + .Where(ji => !ji.IsDeleted) + .Select(ji => new CreateQuoteItemDto + { + Description = ji.Description, + Quantity = ji.Quantity, + SurfaceAreaSqFt = ji.SurfaceAreaSqFt, + EstimatedMinutes = ji.EstimatedMinutes, + CatalogItemId = ji.CatalogItemId, + IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && !ji.Coats.Any() && !ji.IsSalesItem), + IsLaborItem = ji.IsLaborItem, + IsSalesItem = ji.IsSalesItem, + ManualUnitPrice = ji.ManualUnitPrice ?? ((ji.IsGenericItem || ji.IsSalesItem) ? ji.UnitPrice : (decimal?)null), + PowderCostOverride = ji.PowderCostOverride, + IncludePrepCost = ji.IncludePrepCost, + Complexity = ji.Complexity, + Coats = ji.Coats.OrderBy(c => c.Sequence).Select(c => new CreateQuoteItemCoatDto + { + CoverageSqFtPerLb = c.CoverageSqFtPerLb, + TransferEfficiency = c.TransferEfficiency, + PowderCostPerLb = c.PowderCostPerLb, + PowderToOrder = c.PowderToOrder + }).ToList(), + PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto + { + PrepServiceId = ps.PrepServiceId, + EstimatedMinutes = ps.EstimatedMinutes + }).ToList() + }).ToList(); + + if (breakdownItems.Any()) + { + var pr = await _pricingService.CalculateQuoteTotalsAsync( + breakdownItems, job.CompanyId, job.CustomerId, + wizardCosts?.TaxPercent ?? 0m, + job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob, + job.OvenCostId, 1, null); + + ViewBag.JobPricingBreakdown = new QuotePricingBreakdownDto + { + MaterialCosts = pr.MaterialCosts, + LaborCosts = pr.LaborCosts, + EquipmentCosts = pr.EquipmentCosts, + ItemsSubtotal = pr.ItemsSubtotal, + OvenBatchCost = pr.OvenBatchCost, + OvenBatches = pr.OvenBatches, + OvenCycleMinutes = pr.OvenCycleMinutes > 0 ? pr.OvenCycleMinutes : (wizardCosts?.DefaultOvenCycleMinutes ?? 0), + FacilityOverheadCost = pr.FacilityOverheadCost, + FacilityOverheadRatePerHour = pr.FacilityOverheadRatePerHour, + ShopSuppliesAmount = pr.ShopSuppliesAmount, + ShopSuppliesPercent = pr.ShopSuppliesPercent, + OverheadCosts = pr.OverheadCosts, + OverheadPercent = pr.OverheadPercent, + ProfitMargin = pr.ProfitMargin, + ProfitPercent = pr.ProfitPercent, + SubtotalBeforeDiscount = pr.SubtotalBeforeDiscount, + DiscountAmount = pr.DiscountAmount, + DiscountPercent = pr.DiscountPercent, + SubtotalAfterDiscount = pr.SubtotalAfterDiscount, + RushFee = pr.RushFee, + TaxAmount = pr.TaxAmount, + TaxPercent = pr.TaxPercent, + Total = pr.Total + }; + } ViewBag.ComplexitySimplePercent = wizardCosts?.ComplexitySimplePercent ?? 0m; ViewBag.ComplexityModeratePercent = wizardCosts?.ComplexityModeratePercent ?? 5m; ViewBag.ComplexityComplexPercent = wizardCosts?.ComplexityComplexPercent ?? 15m; @@ -465,9 +539,15 @@ public class JobsController : Controller ViewBag.Deposits = jobDeposits; // Materials used on this job via QR scan or manual log - ViewBag.MaterialsUsed = (await _unitOfWork.InventoryTransactions.FindAsync( + var allJobTransactions = (await _unitOfWork.InventoryTransactions.FindAsync( t => t.JobId == id.Value, false, t => t.InventoryItem)) .OrderByDescending(t => t.TransactionDate).ToList(); + ViewBag.MaterialsUsed = allJobTransactions; + + // Pre-logged powder grouped by InventoryItemId (for Complete Job modal pre-fill) + ViewBag.PreLoggedPowder = allJobTransactions + .GroupBy(t => t.InventoryItemId) + .ToDictionary(g => g.Key, g => Math.Abs(g.Sum(t => t.Quantity))); // Job photo subscription limits — used to disable the upload button in the view var photoCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0; @@ -1153,7 +1233,9 @@ public class JobsController : Controller createCosts?.TaxPercent ?? 0m, dto.DiscountType, dto.DiscountValue, dto.IsRushJob, null, 1, null); - job.FinalPrice = totals.Total; + job.FinalPrice = totals.Total; + job.ShopSuppliesAmount = totals.ShopSuppliesAmount; + job.ShopSuppliesPercent = totals.ShopSuppliesPercent; job.UpdatedAt = DateTime.UtcNow; await _unitOfWork.Jobs.UpdateAsync(job); await _unitOfWork.SaveChangesAsync(); @@ -1667,7 +1749,9 @@ public class JobsController : Controller dto.JobItems, companyId, dto.CustomerId, editCosts?.TaxPercent ?? 0m, dto.DiscountType, dto.DiscountValue, dto.IsRushJob, null, 1, null); - job.FinalPrice = totals.Total; + job.FinalPrice = totals.Total; + job.ShopSuppliesAmount = totals.ShopSuppliesAmount; + job.ShopSuppliesPercent = totals.ShopSuppliesPercent; } // Save change history records @@ -2702,6 +2786,15 @@ public class JobsController : Controller job.JobStatusId = completedStatus.Id; } + // Build a mutable credit map: lbs already deducted from inventory for this job + // (via QR scan / LogUsage before completion). We consume this credit per InventoryItemId + // so we only deduct the net delta and never double-subtract. + var preLoggedTransactions = await _unitOfWork.InventoryTransactions.FindAsync( + t => t.JobId == dto.JobId); + var preLoggedCredit = preLoggedTransactions + .GroupBy(t => t.InventoryItemId) + .ToDictionary(g => g.Key, g => Math.Abs(g.Sum(t => t.Quantity))); + // Update actual powder usage for each coat foreach (var coatUsage in dto.CoatUsages) { @@ -2723,37 +2816,49 @@ public class JobsController : Controller coatUsage.ActualPowderUsedLbs.HasValue && coatUsage.ActualPowderUsedLbs.Value > 0) { - var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(jobItemCoat.InventoryItemId.Value); - if (inventoryItem != null) + var invItemId = jobItemCoat.InventoryItemId.Value; + var actualLbs = coatUsage.ActualPowderUsedLbs.Value; + + // Apply available pre-logged credit so we don't double-deduct + var credit = preLoggedCredit.GetValueOrDefault(invItemId, 0m); + var deductNow = Math.Max(0m, actualLbs - credit); + // Consume credit (other coats sharing the same powder get whatever remains) + preLoggedCredit[invItemId] = Math.Max(0m, credit - actualLbs); + + if (deductNow > 0) { - // Create inventory transaction to track the usage - var transaction = new InventoryTransaction + var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(invItemId); + if (inventoryItem != null) { - InventoryItemId = inventoryItem.Id, - TransactionType = InventoryTransactionType.JobUsage, - Quantity = -coatUsage.ActualPowderUsedLbs.Value, // Negative for deduction - UnitCost = inventoryItem.UnitCost, - TotalCost = inventoryItem.UnitCost * coatUsage.ActualPowderUsedLbs.Value, - TransactionDate = DateTime.UtcNow, - JobId = job.Id, - Reference = job.JobNumber, - Notes = $"Powder used for Job {job.JobNumber} - {jobItemCoat.CoatName} ({jobItemCoat.ColorName ?? "N/A"}) by {currentUser!.FirstName} {currentUser.LastName}", - BalanceAfter = inventoryItem.QuantityOnHand - coatUsage.ActualPowderUsedLbs.Value, - CompanyId = job.CompanyId - }; + var transaction = new InventoryTransaction + { + InventoryItemId = inventoryItem.Id, + TransactionType = InventoryTransactionType.JobUsage, + Quantity = -deductNow, + UnitCost = inventoryItem.UnitCost, + TotalCost = inventoryItem.UnitCost * deductNow, + TransactionDate = DateTime.UtcNow, + JobId = job.Id, + Reference = job.JobNumber, + Notes = $"Powder used for Job {job.JobNumber} - {jobItemCoat.CoatName} ({jobItemCoat.ColorName ?? "N/A"}) by {currentUser!.FirstName} {currentUser.LastName}", + BalanceAfter = inventoryItem.QuantityOnHand - deductNow, + CompanyId = job.CompanyId + }; - await _unitOfWork.InventoryTransactions.AddAsync(transaction); - - // Update inventory item quantity - inventoryItem.QuantityOnHand -= coatUsage.ActualPowderUsedLbs.Value; - await _unitOfWork.InventoryItems.UpdateAsync(inventoryItem); + await _unitOfWork.InventoryTransactions.AddAsync(transaction); + inventoryItem.QuantityOnHand -= deductNow; + await _unitOfWork.InventoryItems.UpdateAsync(inventoryItem); + _logger.LogInformation( + "Deducted {Lbs} lbs (net of pre-logged) of {Item} from inventory for Job {JobNumber}. New quantity: {NewQty}", + deductNow, inventoryItem.Name, job.JobNumber, inventoryItem.QuantityOnHand); + } + } + else + { _logger.LogInformation( - "Deducted {Lbs} lbs of {Item} from inventory for Job {JobNumber}. New quantity: {NewQty}", - coatUsage.ActualPowderUsedLbs.Value, - inventoryItem.Name, - job.JobNumber, - inventoryItem.QuantityOnHand); + "Skipped inventory deduction for JobItemCoat {CoatId} — {Lbs} lbs already pre-logged for inventory item {InvItemId}", + coatUsage.JobItemCoatId, actualLbs, invItemId); } } } @@ -3113,7 +3218,9 @@ public class JobsController : Controller model.JobItems, currentUser.CompanyId, job.CustomerId, model.TaxPercent, "None", 0, false, null, 1, null); - job.FinalPrice = totals.Total; + job.FinalPrice = totals.Total; + job.ShopSuppliesAmount = totals.ShopSuppliesAmount; + job.ShopSuppliesPercent = totals.ShopSuppliesPercent; job.UpdatedAt = DateTime.UtcNow; job.UpdatedBy = currentUser.UserName; await _unitOfWork.Jobs.UpdateAsync(job); @@ -3184,11 +3291,15 @@ public class JobsController : Controller var totals = await _pricingService.CalculateQuoteTotalsAsync( remainingDtos, currentUser.CompanyId, job.CustomerId, costs?.TaxPercent ?? 0m, "None", 0, false, null, 1, null); - job.FinalPrice = totals.Total; + job.FinalPrice = totals.Total; + job.ShopSuppliesAmount = totals.ShopSuppliesAmount; + job.ShopSuppliesPercent = totals.ShopSuppliesPercent; } else { - job.FinalPrice = 0; + job.FinalPrice = 0; + job.ShopSuppliesAmount = 0; + job.ShopSuppliesPercent = 0; } job.UpdatedAt = DateTime.UtcNow; @@ -3211,18 +3322,21 @@ public class JobsController : Controller var inventory = await _unitOfWork.InventoryItems.GetAllAsync(false, i => i.InventoryCategory); ViewBag.InventoryCoatings = inventory .Where(i => i.IsActive && i.InventoryCategory?.IsActive == true && i.InventoryCategory.IsCoating) - .OrderBy(i => i.InventoryCategory!.DisplayOrder).ThenBy(i => i.ColorName ?? i.Name) + .OrderBy(i => i.IsIncoming ? 1 : 0).ThenBy(i => i.InventoryCategory!.DisplayOrder).ThenBy(i => i.ColorName ?? i.Name) .Select(i => new { value = i.Id.ToString(), - text = $"{i.InventoryCategory!.DisplayName} - {i.Manufacturer ?? "Generic"} - {i.ColorName ?? i.Name} - {i.ColorCode ?? "N/A"} ({i.UnitCost:C4}/unit)", + text = i.IsIncoming + ? $"[INCOMING] {i.InventoryCategory!.DisplayName} - {i.Manufacturer ?? "Generic"} - {i.ColorName ?? i.Name} - {i.ColorCode ?? "N/A"} ({i.UnitCost:C4}/unit)" + : $"{i.InventoryCategory!.DisplayName} - {i.Manufacturer ?? "Generic"} - {i.ColorName ?? i.Name} - {i.ColorCode ?? "N/A"} ({i.UnitCost:C4}/unit)", coverage = i.CoverageSqFtPerLb ?? 30m, efficiency = i.TransferEfficiency ?? 65m, unitOfMeasure = i.UnitOfMeasure ?? "lbs", - categoryName = i.InventoryCategory.DisplayName, + categoryName = i.InventoryCategory!.DisplayName, costPerLb = i.UnitCost, colorName = i.ColorName ?? i.Name, - colorCode = i.ColorCode ?? "" + colorCode = i.ColorCode ?? "", + isIncoming = i.IsIncoming }).ToList(); var vendors = await _unitOfWork.Vendors.GetAllAsync(false); @@ -3896,9 +4010,11 @@ public class JobsController : Controller } // Update pricing from quote and advance the snapshot so banner clears - job.QuotedPrice = quote.Total; - job.FinalPrice = quote.Total; - job.QuoteSnapshotUpdatedAt = quote.UpdatedAt ?? quote.CreatedAt; + job.QuotedPrice = quote.Total; + job.FinalPrice = quote.Total; + job.ShopSuppliesAmount = quote.ShopSuppliesAmount; + job.ShopSuppliesPercent = quote.ShopSuppliesPercent; + job.QuoteSnapshotUpdatedAt = quote.UpdatedAt ?? quote.CreatedAt; await _unitOfWork.CompleteAsync(); }); @@ -3933,7 +4049,8 @@ public class JobsController : Controller // Operating costs for fallback labor rate and oven rate var opCosts = (await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == companyId)).FirstOrDefault(); var fallbackLaborRate = opCosts?.StandardLaborRate ?? 0m; - var defaultOvenCycleHours = (opCosts?.DefaultOvenCycleMinutes ?? 45) / 60.0m; + var effectiveOvenMinutes = (opCosts?.DefaultOvenCycleMinutes > 0 ? (int?)opCosts!.DefaultOvenCycleMinutes : null) ?? 45; + var defaultOvenCycleHours = effectiveOvenMinutes / 60.0m; // Role cost rates map: role → hourly rate var roleCosts = await _unitOfWork.ShopWorkerRoleCosts.FindAsync(r => r.CompanyId == companyId); @@ -3942,6 +4059,7 @@ public class JobsController : Controller // 1. Powder / Material cost decimal powderCost = 0m; var powderLines = new List(); + bool hasCoatsWithRateButNoQty = false; foreach (var item in job.JobItems) { foreach (var coat in item.Coats) @@ -3960,6 +4078,11 @@ public class JobsController : Controller isActual = coat.ActualPowderUsedLbs.HasValue }); } + else if (costPerLb > 0 && lbs == 0) + { + // Coat has a price/lb but no quantity — surface area missing on the item + hasCoatsWithRateButNoQty = true; + } } } @@ -4040,7 +4163,7 @@ public class JobsController : Controller laborCost = Math.Round(laborCost, 2), ovenCost = Math.Round(ovenCost, 2), ovenLabel, - ovenCycleMinutes = opCosts?.DefaultOvenCycleMinutes ?? 45, + ovenCycleMinutes = effectiveOvenMinutes, reworkCostTotal = Math.Round(reworkCostTotal, 2), reworkBilledToCustomer = Math.Round(reworkBilledToCustomer, 2), netReworkCost = Math.Round(netReworkCost, 2), @@ -4055,6 +4178,7 @@ public class JobsController : Controller powderLines, laborLines, hasPowderData = powderLines.Count > 0, + hasPowderRateButNoQty = hasCoatsWithRateButNoQty && powderLines.Count == 0, hasLaborData = laborLines.Count > 0 }); } diff --git a/src/PowderCoating.Web/Views/Jobs/Details.cshtml b/src/PowderCoating.Web/Views/Jobs/Details.cshtml index 99e7c91..bec22af 100644 --- a/src/PowderCoating.Web/Views/Jobs/Details.cshtml +++ b/src/PowderCoating.Web/Views/Jobs/Details.cshtml @@ -1315,6 +1315,7 @@ } @{ var panelInvoiceId = ViewBag.JobInvoiceId as int?; + var voidedInvoices = ViewBag.JobVoidedInvoices as IEnumerable ?? []; } @if (panelInvoiceId.HasValue) { @@ -1330,6 +1331,13 @@ Create Invoice } + @foreach (var vi in voidedInvoices) + { + + @vi.InvoiceNumber (Voided) + + } Print Work Order @@ -1360,17 +1368,20 @@ - -
+ + @{ + var jobPb = ViewBag.JobPricingBreakdown as PowderCoating.Application.DTOs.Quote.QuotePricingBreakdownDto; + } +
- Pricing + Pricing Summary
@if (!string.IsNullOrWhiteSpace(Model.OvenLabel)) { -
+
Oven @@ -1378,14 +1389,253 @@
} -
- -

@Model.QuotedPrice.ToString("C")

-
-
- -

@Model.FinalPrice.ToString("C")

-
+ + @if (jobPb != null) + { +
+ Items Subtotal: + @jobPb.ItemsSubtotal.ToString("C") +
+ + @if (jobPb.OvenBatchCost > 0) + { +
+ Oven (@jobPb.OvenBatches batch@(jobPb.OvenBatches != 1 ? "es" : "")@(jobPb.OvenCycleMinutes > 0 ? $" × {jobPb.OvenCycleMinutes} min" : "")): + @jobPb.OvenBatchCost.ToString("C") +
+ } + + @if (jobPb.FacilityOverheadCost > 0) + { +
+ Facility Overhead (@jobPb.FacilityOverheadRatePerHour.ToString("C2")/hr): + @jobPb.FacilityOverheadCost.ToString("C") +
+ } + + @if (jobPb.ShopSuppliesAmount > 0) + { +
+ Shop Supplies (@jobPb.ShopSuppliesPercent%): + @jobPb.ShopSuppliesAmount.ToString("C") +
+ } + +
+ Subtotal: + @jobPb.SubtotalBeforeDiscount.ToString("C") +
+ + @if (jobPb.DiscountAmount > 0) + { +
+ + @if (Model.DiscountType == "Percentage") + { + Discount (@Model.DiscountValue% Off): + } + else if (Model.DiscountType == "FixedAmount") + { + Discount (@Model.DiscountValue.ToString("C") Off): + } + else + { + Discount (@jobPb.DiscountPercent.ToString("F1")%): + } + + -@jobPb.DiscountAmount.ToString("C") +
+ @if (!string.IsNullOrWhiteSpace(Model.DiscountReason)) + { +
+ + Reason: @Model.DiscountReason + +
+ } + } + + @if (Model.IsRushJob && jobPb.RushFee > 0) + { +
+ Rush Job Fee: + @jobPb.RushFee.ToString("C") +
+ } + + @if (jobPb.TaxAmount > 0) + { +
+ Tax (@jobPb.TaxPercent.ToString("G29")%): + @jobPb.TaxAmount.ToString("C") +
+ } + +
+
+
Total:
+
@jobPb.Total.ToString("C")
+
+ + @* Collapsible detail breakdown *@ + +
+ @{ + var directCosts = jobPb.MaterialCosts + jobPb.LaborCosts + jobPb.EquipmentCosts; + var hasCostBreakdown = directCosts > 0; + var allCatalog = Model.Items != null && Model.Items.All(i => i.CatalogItemId.HasValue); + } + + @* Section 1: Item Costs *@ +
+
+ Item Costs +
+ @if (hasCostBreakdown) + { +
+ Material (powder + consumables) + @jobPb.MaterialCosts.ToString("C") +
+
+ Labor + @jobPb.LaborCosts.ToString("C") +
+
+ Equipment (oven + booth) + @jobPb.EquipmentCosts.ToString("C") +
+
+ Direct costs + @directCosts.ToString("C") +
+
+ Markup (@jobPb.ProfitPercent.ToString("F0")% baked into item prices) + @((jobPb.ItemsSubtotal - directCosts).ToString("C")) +
+ } + else if (allCatalog) + { +
All items use fixed catalog pricing — no per-category cost split available.
+ } + else + { +
Cost breakdown not available.
+ } +
+ Items subtotal + @jobPb.ItemsSubtotal.ToString("C") +
+
+ + @* Section 2: Job-Level Additions *@ + @if (jobPb.OvenBatchCost > 0 || jobPb.FacilityOverheadCost > 0 || jobPb.ShopSuppliesAmount > 0 || jobPb.OverheadCosts > 0) + { +
+
+ Job-Level Additions +
+ @if (jobPb.OvenBatchCost > 0) + { +
+ Oven batch (@jobPb.OvenBatches batch@(jobPb.OvenBatches != 1 ? "es" : "")@(jobPb.OvenCycleMinutes > 0 ? $", {jobPb.OvenCycleMinutes} min/cycle" : "")) + @jobPb.OvenBatchCost.ToString("C") +
+ } + @if (jobPb.FacilityOverheadCost > 0) + { +
+ Facility overhead (@jobPb.FacilityOverheadRatePerHour.ToString("C2")/hr × estimated hours) + @jobPb.FacilityOverheadCost.ToString("C") +
+ } + @if (jobPb.ShopSuppliesAmount > 0) + { +
+ Shop supplies (@jobPb.ShopSuppliesPercent.ToString("F1")%) + @jobPb.ShopSuppliesAmount.ToString("C") +
+ } + @if (jobPb.OverheadCosts > 0) + { +
+ Overhead (@jobPb.OverheadPercent.ToString("F1")%) + @jobPb.OverheadCosts.ToString("C") +
+ } +
+ } + + @* Section 3: Final Calculation *@ +
+
+ Final Calculation +
+
+ Subtotal + @jobPb.SubtotalBeforeDiscount.ToString("C") +
+ @if (jobPb.DiscountAmount > 0) + { +
+ Discount (@jobPb.DiscountPercent.ToString("F1")%) + -@jobPb.DiscountAmount.ToString("C") +
+
+ After discount + @jobPb.SubtotalAfterDiscount.ToString("C") +
+ } + @if (jobPb.RushFee > 0) + { +
+ Rush fee + @jobPb.RushFee.ToString("C") +
+ } + @if (jobPb.TaxAmount > 0) + { +
+ Tax (@jobPb.TaxPercent.ToString("G29")%) + @jobPb.TaxAmount.ToString("C") +
+ } +
+ Total + @jobPb.Total.ToString("C") +
+ @{ + var jobTotalDirectCost = jobPb.MaterialCosts + jobPb.LaborCosts + jobPb.EquipmentCosts + jobPb.OvenBatchCost + jobPb.FacilityOverheadCost + jobPb.ShopSuppliesAmount; + var jobGrossProfit = jobPb.Total - jobTotalDirectCost; + var jobEffectiveMargin = jobPb.Total > 0 ? (jobGrossProfit / jobPb.Total * 100m) : 0m; + } + @if (jobTotalDirectCost > 0) + { +
+ Effective gross margin + @jobEffectiveMargin.ToString("F1")% +
+ } +
+
+ } + else + { + @* Fallback: no items yet *@ + @if (Model.QuoteId.HasValue) + { +
+ +

@Model.QuotedPrice.ToString("C")

+
+ } +
+ +

@Model.FinalPrice.ToString("C")

+
+ }
@@ -2584,7 +2834,8 @@ // Notes const notes = []; - if (!d.hasPowderData) notes.push('⚠ Add powder cost per lb on coat records to include material cost.'); + if (!d.hasPowderData && d.hasPowderRateButNoQty) notes.push('⚠ Surface area not set on one or more items — edit the item and enter a surface area to calculate powder cost.'); + else if (!d.hasPowderData) notes.push('⚠ Add powder cost per lb on coat records to include material cost.'); if (!d.hasLaborData) notes.push('⚠ Log time entries to include labor cost.'); if (d.laborLines?.some(l => l.usingFallback)) notes.push('* One or more workers using standard labor rate fallback.'); document.getElementById('costingNotes').innerHTML = notes.map(n => `
${n}
`).join(''); @@ -2618,6 +2869,7 @@