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:
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +215,8 @@ public static class HelpKnowledgeBase
|
||||
|
||||
**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.
|
||||
|
||||
**Pricing Mode (Markup vs Margin):** In Company Settings → Operating Costs you can choose between two pricing modes:
|
||||
- *Markup on Materials* (default) — the General Markup % is applied as a markup on top of calculated costs: `price = cost × (1 + markup%)`. A 25% markup on a $100 cost = $125.
|
||||
- *Target Margin on Total Cost* — the markup % is treated as a target gross margin: `price = cost ÷ (1 − margin%)`. A 25% margin on a $100 cost = $133.33. The difference grows at higher percentages.
|
||||
@@ -305,6 +307,10 @@ public static class HelpKnowledgeBase
|
||||
|
||||
**Changing the customer on a job:** On the Job Details page, the Customer field is an always-visible dropdown. Select a different customer — a confirmation banner appears. Click **Save** to apply or **Cancel** to revert. Use this to correct a misassigned job or to move a walk-in job to a customer's proper record after they've been added to the system.
|
||||
|
||||
**Inline item price editing:** On the Job Details page, any unit price, quantity, or item description can be edited in-place without opening the full edit form. Click the value — it becomes an input field. Type the new value, then press Enter or click away to save (Escape cancels). The pricing summary card (Items Subtotal, Subtotal, Tax, and Total) and the Job Costing card both update immediately without a page reload.
|
||||
|
||||
**Job Costing revenue:** The Job Costing card uses the job's Final Price as the revenue figure — not the linked invoice total. This means inline price edits are reflected in the profit margin estimate immediately, even before an invoice exists.
|
||||
|
||||
**Completing a job:** When a job is ready to mark complete, click the **Complete Job** button on the Job Details page. A modal appears asking you to confirm the completion date, actual hours spent, and final price. If the job used powder from inventory, you will be asked to enter the actual lbs used — the modal groups all coats by unique powder color (not per coat or per item) so you fill in one quantity per powder. The system deducts the entered amounts from inventory, crediting any quantities already logged via QR scan. Once confirmed, the job advances to Completed status, and you are prompted to create the invoice if one does not exist.
|
||||
|
||||
**Creating an invoice from a job:** On the Job Details page, look for the Invoice section and click "Create Invoice." The system pre-fills all line items, pricing, discount, tax rate, payment terms, and due date from the job and customer automatically. Line item descriptions include the coat color(s) for each item (e.g., "Color1 / Color2" if multiple coats), helping customers distinguish repeated items on the invoice. Review the Totals panel on the right — if a discount was applied to the job it will show as a red "Discount Applied" line. Adjust anything you need, then save.
|
||||
@@ -357,6 +363,8 @@ public static class HelpKnowledgeBase
|
||||
|
||||
**Payment methods:** Cash, Check, Credit/Debit Card, Bank Transfer (ACH), Digital Payment, Store Credit
|
||||
|
||||
**Inline item editing on invoices:** On the Invoice Details page, unit prices, quantities, and item descriptions can be edited in-place while the invoice is in Draft status. Click the value — it becomes an input field. Press Enter or click away to save; press Escape to cancel. The invoice total updates immediately. Line items are locked once the invoice is Sent.
|
||||
|
||||
**Sending an invoice:** Invoice Details → "Send" — emails PDF to customer.
|
||||
|
||||
**Online Payments:** [/Invoices/OnlinePayments](/Invoices/OnlinePayments) — Lists invoices with a shareable payment link the customer can pay without logging in. Requires Stripe Connect to be set up first (see below).
|
||||
|
||||
@@ -78,6 +78,32 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="inline-price-editing" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-pencil-square text-primary me-2"></i>Inline Price Editing
|
||||
</h2>
|
||||
<p>
|
||||
While an invoice is in <strong>Draft</strong> status, you can edit line item prices,
|
||||
quantities, and descriptions directly on the Invoice Details page — without
|
||||
opening the full Edit form.
|
||||
</p>
|
||||
<ol class="mb-3">
|
||||
<li class="mb-2">Click a <strong>unit price</strong>, <strong>quantity</strong>, or <strong>description</strong> cell. The cell turns into an input field.</li>
|
||||
<li class="mb-2">Type the new value.</li>
|
||||
<li class="mb-2">Press <kbd>Enter</kbd> or click anywhere outside the field to save. Press <kbd>Esc</kbd> to cancel.</li>
|
||||
</ol>
|
||||
<p>
|
||||
The line total and the invoice grand total update immediately without reloading the page.
|
||||
</p>
|
||||
<div class="alert alert-permanent alert-secondary d-flex gap-2 mb-0" role="alert">
|
||||
<i class="bi bi-info-circle flex-shrink-0 mt-1"></i>
|
||||
<div>
|
||||
Once an invoice is marked <strong>Sent</strong>, line items are locked and inline
|
||||
editing is disabled. To correct a sent invoice, void it and create a new one.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="invoice-statuses" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-tag text-primary me-2"></i>Invoice Statuses
|
||||
|
||||
@@ -309,6 +309,35 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="inline-price-editing" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-pencil-square text-primary me-2"></i>Inline Price Editing
|
||||
</h2>
|
||||
<p>
|
||||
On the Job Details page you can edit any item’s unit price, quantity, or description
|
||||
directly in the table — without opening the full Edit form.
|
||||
</p>
|
||||
<ol class="mb-3">
|
||||
<li class="mb-2">Click a <strong>unit price</strong>, <strong>quantity</strong>, or <strong>description</strong> cell in the Items table. The cell turns into an input field.</li>
|
||||
<li class="mb-2">Type the new value.</li>
|
||||
<li class="mb-2">Press <kbd>Enter</kbd> or click anywhere outside the field to save. Press <kbd>Esc</kbd> to cancel without saving.</li>
|
||||
</ol>
|
||||
<p>
|
||||
After saving, the line total updates immediately and the pricing summary card (Items
|
||||
Subtotal, Subtotal, Tax, and Grand Total) refreshes — no page reload required.
|
||||
The <strong>Job Costing</strong> card also recalculates automatically so your
|
||||
profit margin estimate stays current.
|
||||
</p>
|
||||
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
|
||||
<i class="bi bi-info-circle-fill flex-shrink-0 mt-1"></i>
|
||||
<div>
|
||||
The <strong>Job Costing</strong> section calculates revenue from the job’s
|
||||
current Final Price — not from the linked invoice total. Inline price edits
|
||||
are reflected in the costing analysis immediately, even before an invoice is created.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="converting-from-quote" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-arrow-left-right text-primary me-2"></i>Converting from a Quote
|
||||
|
||||
@@ -213,6 +213,25 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="inline-price-editing" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-pencil-square text-primary me-2"></i>Inline Price Editing
|
||||
</h2>
|
||||
<p>
|
||||
On the Quote Details page you can edit any item’s unit price, quantity, or
|
||||
description directly in the table without opening the full Edit form.
|
||||
</p>
|
||||
<ol class="mb-3">
|
||||
<li class="mb-2">Click a <strong>unit price</strong>, <strong>quantity</strong>, or <strong>description</strong> cell in the Items table. The cell turns into an input field.</li>
|
||||
<li class="mb-2">Type the new value.</li>
|
||||
<li class="mb-2">Press <kbd>Enter</kbd> or click outside the field to save. Press <kbd>Esc</kbd> to cancel without saving.</li>
|
||||
</ol>
|
||||
<p>
|
||||
The pricing summary (subtotal, discount, tax, and grand total) updates immediately
|
||||
— no page reload required.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="quote-statuses" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-tag text-primary me-2"></i>Quote Statuses
|
||||
|
||||
@@ -1479,7 +1479,7 @@
|
||||
{
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span>Items Subtotal:</span>
|
||||
<strong>@jobPb.ItemsSubtotal.ToString("C")</strong>
|
||||
<strong data-pb="itemsSubtotal">@jobPb.ItemsSubtotal.ToString("C")</strong>
|
||||
</div>
|
||||
|
||||
@if (jobPb.OvenBatchCost > 0)
|
||||
@@ -1508,7 +1508,7 @@
|
||||
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span>Subtotal:</span>
|
||||
<strong>@jobPb.SubtotalBeforeDiscount.ToString("C")</strong>
|
||||
<strong data-pb="subtotalBeforeDiscount">@jobPb.SubtotalBeforeDiscount.ToString("C")</strong>
|
||||
</div>
|
||||
|
||||
@if (jobPb.DiscountAmount > 0)
|
||||
@@ -1552,14 +1552,14 @@
|
||||
{
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span>Tax (@jobPb.TaxPercent.ToString("G29")%):</span>
|
||||
<strong>@jobPb.TaxAmount.ToString("C")</strong>
|
||||
<strong data-pb="taxAmount">@jobPb.TaxAmount.ToString("C")</strong>
|
||||
</div>
|
||||
}
|
||||
|
||||
<hr />
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<h5>Total:</h5>
|
||||
<h5 class="text-primary"><strong>@jobPb.Total.ToString("C")</strong></h5>
|
||||
<h5 class="text-primary"><strong class="job-final-price-display">@jobPb.Total.ToString("C")</strong></h5>
|
||||
</div>
|
||||
|
||||
@* Collapsible detail breakdown *@
|
||||
@@ -2427,7 +2427,8 @@
|
||||
subtotalAfterDiscount: '[data-pb="subtotalAfterDiscount"]',
|
||||
taxAmount: '[data-pb="taxAmount"]',
|
||||
finalPrice: '.job-final-price-display'
|
||||
}
|
||||
},
|
||||
afterSave: () => { if (typeof costing !== 'undefined') costing.load(); }
|
||||
};
|
||||
</script>
|
||||
<script>
|
||||
|
||||
@@ -28,13 +28,10 @@
|
||||
// Each key must exactly match a property returned by the server's PatchItem action.
|
||||
function updateTotals(cfg, data) {
|
||||
const t = cfg.totals || {};
|
||||
console.debug('[inline-edit] updateTotals data:', data, 'totals cfg:', t);
|
||||
Object.entries(t).forEach(([key, selector]) => {
|
||||
const val = data[key];
|
||||
const els = document.querySelectorAll(selector);
|
||||
console.debug(`[inline-edit] ${key} → "${selector}": val=${val}, elements=${els.length}`);
|
||||
if (selector && val !== undefined && val !== null) {
|
||||
els.forEach(el => { el.textContent = fmt(val); });
|
||||
document.querySelectorAll(selector).forEach(el => { el.textContent = fmt(val); });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -143,6 +140,9 @@
|
||||
// Update document-level totals
|
||||
updateTotals(cfg, data);
|
||||
|
||||
// Optional post-save hook (e.g. refresh a dependent card)
|
||||
if (typeof cfg.afterSave === 'function') cfg.afterSave(data);
|
||||
|
||||
// Re-attach click listener for next edit
|
||||
attachListeners(cfg, span);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user