Inline item editing on Job Details with live pricing and costing updates

- PatchItem: add case-insensitive JSON deserialization; add legacy fallback
  that computes a live breakdown from job items when PricingBreakdownJson is null
- PatchItem: return itemsSubtotal, subtotalBeforeDiscount, subtotalAfterDiscount,
  taxAmount in JSON response for immediate DOM updates
- GetCostingBreakdown: use job.FinalPrice as revenue (not invoice total) so
  costing figures reflect inline edits before an invoice exists
- Details.cshtml: add data-pb attributes to visible pricing rows; add
  job-final-price-display class to visible Total element
- Details.cshtml: wire afterSave callback to call costing.load() after each edit
- inline-item-edit.js: add afterSave hook in commit(); clean up debug logging
- Help docs: add Inline Price Editing sections to Jobs, Quotes, and Invoices
  help articles; add inline editing + job costing revenue notes to AI knowledge base

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 23:56:36 -04:00
parent ec925f9e08
commit 1bb07162cd
7 changed files with 122 additions and 24 deletions
@@ -3983,10 +3983,11 @@ public class JobsController : Controller
ovenCost = opCosts.OvenOperatingCostPerHour * defaultOvenCycleHours;
}
// 4. Revenue
decimal revenue = job.Invoice != null
? job.Invoice.Total
: (job.FinalPrice > 0 ? job.FinalPrice : job.QuotedPrice);
// 4. Revenue — prefer FinalPrice (reflects inline edits and job-level changes);
// fall back to Invoice.Total only when FinalPrice is zero (voided/zeroed job).
decimal revenue = job.FinalPrice > 0
? job.FinalPrice
: (job.Invoice?.Total ?? job.QuotedPrice);
// 5. Rework costs from linked rework jobs
var reworkRecords = await _unitOfWork.ReworkRecords.FindAsync(
@@ -4022,7 +4023,7 @@ public class JobsController : Controller
return Json(new {
revenue = Math.Round(revenue, 2),
revenueSource = job.Invoice != null ? "Invoice" : (job.FinalPrice > 0 ? "Final Price" : "Quoted Price"),
revenueSource = job.FinalPrice > 0 ? "Final Price" : (job.Invoice != null ? "Invoice" : "Quoted Price"),
powderCost = Math.Round(powderCost, 2),
laborCost = Math.Round(laborCost, 2),
ovenCost = Math.Round(ovenCost, 2),
@@ -4245,10 +4246,13 @@ public class JobsController : Controller
var delta = item.TotalPrice - oldTotal;
job.FinalPrice = Math.Round(job.FinalPrice + delta, 2);
// Keep the stored pricing snapshot in sync so the breakdown panel stays consistent
// Keep the stored pricing snapshot in sync so the breakdown panel stays consistent.
// Case-insensitive options handle JSON stored before PascalCase serialization was enforced.
QuotePricingBreakdownDto? pbFinal = null;
var jsonOpts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
{
var pb = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson);
var pb = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson, jsonOpts);
if (pb != null)
{
pb.ItemsSubtotal += delta;
@@ -4258,24 +4262,35 @@ public class JobsController : Controller
pb.Total = Math.Round(pb.SubtotalAfterDiscount + pb.RushFee + pb.TaxAmount, 2);
job.FinalPrice = pb.Total;
job.PricingBreakdownJson = JsonSerializer.Serialize(pb);
pbFinal = pb;
}
}
await _unitOfWork.Jobs.UpdateAsync(job);
await _unitOfWork.CompleteAsync();
// Deserialize again after possible re-serialization to get final values
QuotePricingBreakdownDto? pbFinal = null;
if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
pbFinal = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson);
// For legacy jobs without a stored snapshot, derive breakdown from live item totals.
if (pbFinal == null)
{
var allItems = await _unitOfWork.JobItems.FindAsync(ji => ji.JobId == job.Id && !ji.IsDeleted);
var itemsSubtotal = allItems.Sum(ji => ji.TotalPrice);
var subtotal = itemsSubtotal + job.OvenBatchCost + job.ShopSuppliesAmount;
pbFinal = new QuotePricingBreakdownDto
{
ItemsSubtotal = itemsSubtotal,
SubtotalBeforeDiscount = subtotal,
SubtotalAfterDiscount = subtotal,
Total = job.FinalPrice
};
}
return Json(new {
lineTotal = item.TotalPrice,
finalPrice = job.FinalPrice,
itemsSubtotal = pbFinal?.ItemsSubtotal,
subtotalBeforeDiscount = pbFinal?.SubtotalBeforeDiscount,
subtotalAfterDiscount = pbFinal?.SubtotalAfterDiscount,
taxAmount = pbFinal?.TaxAmount
itemsSubtotal = pbFinal.ItemsSubtotal,
subtotalBeforeDiscount = pbFinal.SubtotalBeforeDiscount,
subtotalAfterDiscount = pbFinal.SubtotalAfterDiscount,
taxAmount = pbFinal.TaxAmount
});
}
}