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 <noreply@anthropic.com>
This commit is contained in:
2026-05-08 20:47:44 -04:00
parent 3278152d83
commit 0d980e651a
5 changed files with 454 additions and 59 deletions
@@ -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<dynamic>();
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<object>();
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
});
}