Inline item editing on details pages; fix Stripe receipt_email
Allow description, quantity, and price to be edited inline on Quote, Job, and Invoice details pages without re-opening the wizard. Coating and prep service rows remain read-only by design. Invoice editing is gated to Draft/Sent/Overdue statuses; totals update live in the DOM. Remove receipt_email from Stripe PaymentIntent creation so customers can use any email they choose at checkout — Stripe validates format and sends the receipt to whatever the customer enters in the Payment Element, eliminating the risk of a stored email mismatch blocking a payment from processing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -20,7 +20,6 @@ public interface IStripeConnectService
|
|||||||
decimal invoiceTotal,
|
decimal invoiceTotal,
|
||||||
decimal surchargeAmount,
|
decimal surchargeAmount,
|
||||||
string currency,
|
string currency,
|
||||||
string customerEmail,
|
|
||||||
string invoiceNumber,
|
string invoiceNumber,
|
||||||
int invoiceId);
|
int invoiceId);
|
||||||
|
|
||||||
@@ -33,7 +32,6 @@ public interface IStripeConnectService
|
|||||||
decimal depositAmount,
|
decimal depositAmount,
|
||||||
decimal surchargeAmount,
|
decimal surchargeAmount,
|
||||||
string currency,
|
string currency,
|
||||||
string customerEmail,
|
|
||||||
string quoteNumber,
|
string quoteNumber,
|
||||||
int quoteId);
|
int quoteId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,7 +162,6 @@ public class StripeConnectService : IStripeConnectService
|
|||||||
decimal invoiceTotal,
|
decimal invoiceTotal,
|
||||||
decimal surchargeAmount,
|
decimal surchargeAmount,
|
||||||
string currency,
|
string currency,
|
||||||
string customerEmail,
|
|
||||||
string invoiceNumber,
|
string invoiceNumber,
|
||||||
int invoiceId)
|
int invoiceId)
|
||||||
{
|
{
|
||||||
@@ -175,7 +174,6 @@ public class StripeConnectService : IStripeConnectService
|
|||||||
{
|
{
|
||||||
Amount = amountInCents,
|
Amount = amountInCents,
|
||||||
Currency = currency.ToLower(),
|
Currency = currency.ToLower(),
|
||||||
ReceiptEmail = customerEmail,
|
|
||||||
Description = $"Invoice {invoiceNumber}",
|
Description = $"Invoice {invoiceNumber}",
|
||||||
Metadata = new Dictionary<string, string>
|
Metadata = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
@@ -215,7 +213,6 @@ public class StripeConnectService : IStripeConnectService
|
|||||||
decimal depositAmount,
|
decimal depositAmount,
|
||||||
decimal surchargeAmount,
|
decimal surchargeAmount,
|
||||||
string currency,
|
string currency,
|
||||||
string customerEmail,
|
|
||||||
string quoteNumber,
|
string quoteNumber,
|
||||||
int quoteId)
|
int quoteId)
|
||||||
{
|
{
|
||||||
@@ -228,7 +225,6 @@ public class StripeConnectService : IStripeConnectService
|
|||||||
{
|
{
|
||||||
Amount = amountInCents,
|
Amount = amountInCents,
|
||||||
Currency = currency.ToLower(),
|
Currency = currency.ToLower(),
|
||||||
ReceiptEmail = customerEmail,
|
|
||||||
Description = $"Deposit for quote {quoteNumber}",
|
Description = $"Deposit for quote {quoteNumber}",
|
||||||
Metadata = new Dictionary<string, string>
|
Metadata = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3035,6 +3035,50 @@ public class InvoicesController : Controller
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inline-edits description, quantity, and unit price on a single invoice line item.
|
||||||
|
/// Blocked on paid/voided invoices (same gate as the full Edit action).
|
||||||
|
/// Returns updated totals so the page can reflect the change without a reload.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> PatchItem([FromBody] PatchInvoiceItemRequest request)
|
||||||
|
{
|
||||||
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
|
if (currentUser == null) return Unauthorized();
|
||||||
|
|
||||||
|
var item = await _unitOfWork.InvoiceItems.GetByIdAsync(request.ItemId);
|
||||||
|
if (item == null) return NotFound();
|
||||||
|
|
||||||
|
var invoice = await _unitOfWork.Invoices.GetByIdAsync(item.InvoiceId);
|
||||||
|
if (invoice == null || invoice.CompanyId != currentUser.CompanyId) return NotFound();
|
||||||
|
|
||||||
|
if (invoice.Status is not (InvoiceStatus.Draft or InvoiceStatus.Sent or InvoiceStatus.Overdue))
|
||||||
|
return BadRequest(new { error = "Cannot edit items on a paid or voided invoice." });
|
||||||
|
|
||||||
|
item.Description = request.Description.Trim();
|
||||||
|
item.Quantity = request.Quantity;
|
||||||
|
item.UnitPrice = request.UnitPrice;
|
||||||
|
item.TotalPrice = Math.Round(request.Quantity * request.UnitPrice, 2);
|
||||||
|
await _unitOfWork.InvoiceItems.UpdateAsync(item);
|
||||||
|
|
||||||
|
var allItems = await _unitOfWork.InvoiceItems.FindAsync(ii => ii.InvoiceId == invoice.Id);
|
||||||
|
var newSubTotal = allItems.Sum(i => i.TotalPrice);
|
||||||
|
invoice.SubTotal = newSubTotal;
|
||||||
|
invoice.TaxAmount = Math.Round(newSubTotal * invoice.TaxPercent / 100m, 2);
|
||||||
|
invoice.Total = Math.Round(newSubTotal - invoice.DiscountAmount + invoice.TaxAmount, 2);
|
||||||
|
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
return Json(new {
|
||||||
|
lineTotal = item.TotalPrice,
|
||||||
|
subtotal = invoice.SubTotal,
|
||||||
|
taxAmount = invoice.TaxAmount,
|
||||||
|
total = invoice.Total,
|
||||||
|
balanceDue = invoice.BalanceDue
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns logo bytes and content type for PDF generation.
|
/// Returns logo bytes and content type for PDF generation.
|
||||||
/// Prefers blob-stored logos (LogoFilePath) over the legacy DB column (LogoData).
|
/// Prefers blob-stored logos (LogoFilePath) over the legacy DB column (LogoData).
|
||||||
@@ -3050,3 +3094,11 @@ public class InvoicesController : Controller
|
|||||||
return (company.LogoData, company.LogoContentType);
|
return (company.LogoData, company.LogoContentType);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class PatchInvoiceItemRequest
|
||||||
|
{
|
||||||
|
public int ItemId { get; set; }
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public decimal Quantity { get; set; }
|
||||||
|
public decimal UnitPrice { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -4216,9 +4216,69 @@ public class JobsController : Controller
|
|||||||
return Json(new { success = false, message = "An error occurred. Please try again." });
|
return Json(new { success = false, message = "An error occurred. Please try again." });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inline-edits description, quantity, and unit price on a single job line item.
|
||||||
|
/// Adjusts FinalPrice and the stored PricingBreakdownJson snapshot by the price delta.
|
||||||
|
/// Returns updated totals so the page can reflect the change without a reload.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> PatchItem([FromBody] PatchJobItemRequest request)
|
||||||
|
{
|
||||||
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
|
if (currentUser == null) return Unauthorized();
|
||||||
|
|
||||||
|
var item = await _unitOfWork.JobItems.GetByIdAsync(request.ItemId);
|
||||||
|
if (item == null) return NotFound();
|
||||||
|
|
||||||
|
var job = await _unitOfWork.Jobs.GetByIdAsync(item.JobId);
|
||||||
|
if (job == null || job.CompanyId != currentUser.CompanyId) return NotFound();
|
||||||
|
|
||||||
|
var oldTotal = item.TotalPrice;
|
||||||
|
item.Description = request.Description.Trim();
|
||||||
|
item.Quantity = request.Quantity;
|
||||||
|
item.UnitPrice = request.UnitPrice;
|
||||||
|
item.TotalPrice = Math.Round(request.Quantity * request.UnitPrice, 2);
|
||||||
|
await _unitOfWork.JobItems.UpdateAsync(item);
|
||||||
|
|
||||||
|
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
|
||||||
|
if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
|
||||||
|
{
|
||||||
|
var pb = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson);
|
||||||
|
if (pb != null)
|
||||||
|
{
|
||||||
|
pb.ItemsSubtotal += delta;
|
||||||
|
pb.SubtotalBeforeDiscount += delta;
|
||||||
|
pb.SubtotalAfterDiscount = pb.SubtotalBeforeDiscount - pb.DiscountAmount;
|
||||||
|
pb.TaxAmount = Math.Round(pb.SubtotalAfterDiscount * pb.TaxPercent / 100m, 2);
|
||||||
|
pb.Total = Math.Round(pb.SubtotalAfterDiscount + pb.RushFee + pb.TaxAmount, 2);
|
||||||
|
job.FinalPrice = pb.Total;
|
||||||
|
job.PricingBreakdownJson = JsonSerializer.Serialize(pb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _unitOfWork.Jobs.UpdateAsync(job);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
return Json(new {
|
||||||
|
lineTotal = item.TotalPrice,
|
||||||
|
finalPrice = job.FinalPrice
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class DeleteTimeEntryRequest { public int Id { get; set; } }
|
public class DeleteTimeEntryRequest { public int Id { get; set; } }
|
||||||
|
public class PatchJobItemRequest
|
||||||
|
{
|
||||||
|
public int ItemId { get; set; }
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public decimal Quantity { get; set; }
|
||||||
|
public decimal UnitPrice { get; set; }
|
||||||
|
}
|
||||||
public class LogMaterialRequest
|
public class LogMaterialRequest
|
||||||
{
|
{
|
||||||
public int JobId { get; set; }
|
public int JobId { get; set; }
|
||||||
|
|||||||
@@ -127,8 +127,6 @@ public class PaymentController : Controller
|
|||||||
return BadRequest(new { error = "Invalid payment amount." });
|
return BadRequest(new { error = "Invalid payment amount." });
|
||||||
|
|
||||||
var surcharge = CalculateSurcharge(request.Amount, company!);
|
var surcharge = CalculateSurcharge(request.Amount, company!);
|
||||||
var customer = await _context.Customers.AsNoTracking()
|
|
||||||
.FirstOrDefaultAsync(c => c.Id == invoice.CustomerId);
|
|
||||||
|
|
||||||
var (success, clientSecret, paymentIntentId, stripeError) =
|
var (success, clientSecret, paymentIntentId, stripeError) =
|
||||||
await _stripeConnect.CreatePaymentIntentAsync(
|
await _stripeConnect.CreatePaymentIntentAsync(
|
||||||
@@ -136,7 +134,6 @@ public class PaymentController : Controller
|
|||||||
invoiceTotal: request.Amount,
|
invoiceTotal: request.Amount,
|
||||||
surchargeAmount: surcharge,
|
surchargeAmount: surcharge,
|
||||||
currency: "usd",
|
currency: "usd",
|
||||||
customerEmail: customer?.Email ?? string.Empty,
|
|
||||||
invoiceNumber: invoice.InvoiceNumber,
|
invoiceNumber: invoice.InvoiceNumber,
|
||||||
invoiceId: invoice.Id);
|
invoiceId: invoice.Id);
|
||||||
|
|
||||||
@@ -296,7 +293,6 @@ public class PaymentController : Controller
|
|||||||
|
|
||||||
var depositAmount = Math.Round(quote!.Total * (quote.DepositPercent / 100m), 2);
|
var depositAmount = Math.Round(quote!.Total * (quote.DepositPercent / 100m), 2);
|
||||||
var surcharge = CalculateSurcharge(depositAmount, company!);
|
var surcharge = CalculateSurcharge(depositAmount, company!);
|
||||||
var customerEmail = quote.Customer?.Email ?? quote.ProspectEmail ?? string.Empty;
|
|
||||||
|
|
||||||
var (success, clientSecret, paymentIntentId, stripeError) =
|
var (success, clientSecret, paymentIntentId, stripeError) =
|
||||||
await _stripeConnect.CreateDepositPaymentIntentAsync(
|
await _stripeConnect.CreateDepositPaymentIntentAsync(
|
||||||
@@ -304,7 +300,6 @@ public class PaymentController : Controller
|
|||||||
depositAmount: depositAmount,
|
depositAmount: depositAmount,
|
||||||
surchargeAmount: surcharge,
|
surchargeAmount: surcharge,
|
||||||
currency: "usd",
|
currency: "usd",
|
||||||
customerEmail: customerEmail,
|
|
||||||
quoteNumber: quote.QuoteNumber,
|
quoteNumber: quote.QuoteNumber,
|
||||||
quoteId: quote.Id);
|
quoteId: quote.Id);
|
||||||
|
|
||||||
|
|||||||
@@ -3824,6 +3824,49 @@ public class QuotesController : Controller
|
|||||||
}
|
}
|
||||||
return (company.LogoData, company.LogoContentType);
|
return (company.LogoData, company.LogoContentType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inline-edits description, quantity, and unit price on a single quote line item.
|
||||||
|
/// Adjusts stored quote totals by the price delta so the sidebar stays accurate.
|
||||||
|
/// Returns updated totals so the page can reflect the change without a reload.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> PatchItem([FromBody] PatchQuoteItemRequest request)
|
||||||
|
{
|
||||||
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
|
if (currentUser == null) return Unauthorized();
|
||||||
|
|
||||||
|
var item = await _unitOfWork.QuoteItems.GetByIdAsync(request.ItemId);
|
||||||
|
if (item == null) return NotFound();
|
||||||
|
|
||||||
|
var quote = await _unitOfWork.Quotes.GetByIdAsync(item.QuoteId);
|
||||||
|
if (quote == null || quote.CompanyId != currentUser.CompanyId) return NotFound();
|
||||||
|
|
||||||
|
var oldTotal = item.TotalPrice;
|
||||||
|
item.Description = request.Description.Trim();
|
||||||
|
item.Quantity = request.Quantity;
|
||||||
|
item.UnitPrice = request.UnitPrice;
|
||||||
|
item.TotalPrice = Math.Round(request.Quantity * request.UnitPrice, 2);
|
||||||
|
await _unitOfWork.QuoteItems.UpdateAsync(item);
|
||||||
|
|
||||||
|
// Cascade delta through stored totals without re-running the pricing engine
|
||||||
|
var delta = item.TotalPrice - oldTotal;
|
||||||
|
quote.ItemsSubtotal += delta;
|
||||||
|
quote.SubTotal += delta;
|
||||||
|
quote.SubtotalAfterDiscount = quote.SubTotal - quote.DiscountAmount;
|
||||||
|
quote.TaxAmount = Math.Round(quote.SubtotalAfterDiscount * quote.TaxPercent / 100m, 2);
|
||||||
|
quote.Total = Math.Round(quote.SubtotalAfterDiscount + quote.RushFee + quote.TaxAmount, 2);
|
||||||
|
await _unitOfWork.Quotes.UpdateAsync(quote);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
return Json(new {
|
||||||
|
lineTotal = item.TotalPrice,
|
||||||
|
subtotal = quote.SubTotal,
|
||||||
|
taxAmount = quote.TaxAmount,
|
||||||
|
total = quote.Total
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request model for AJAX pricing calculation
|
// Request model for AJAX pricing calculation
|
||||||
@@ -3834,3 +3877,11 @@ public class UpdateQuoteStatusRequest
|
|||||||
public int QuoteId { get; set; }
|
public int QuoteId { get; set; }
|
||||||
public int StatusId { get; set; }
|
public int StatusId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class PatchQuoteItemRequest
|
||||||
|
{
|
||||||
|
public int ItemId { get; set; }
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public decimal Quantity { get; set; }
|
||||||
|
public decimal UnitPrice { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -230,21 +230,21 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var item in Model.InvoiceItems)
|
@foreach (var item in Model.InvoiceItems)
|
||||||
{
|
{
|
||||||
<tr>
|
<tr data-item-id="@item.Id">
|
||||||
<td>
|
<td>
|
||||||
<div class="fw-semibold">@item.Description</div>
|
<span class="fw-semibold" data-inline-field="description" data-raw-value="@item.Description">@item.Description</span>
|
||||||
@if (!string.IsNullOrWhiteSpace(item.ColorName))
|
@if (!string.IsNullOrWhiteSpace(item.ColorName))
|
||||||
{
|
{
|
||||||
<small class="text-muted">@item.ColorName</small>
|
<small class="text-muted d-block">@item.ColorName</small>
|
||||||
}
|
}
|
||||||
@if (!string.IsNullOrWhiteSpace(item.Notes))
|
@if (!string.IsNullOrWhiteSpace(item.Notes))
|
||||||
{
|
{
|
||||||
<small class="text-muted d-block">@item.Notes</small>
|
<small class="text-muted d-block">@item.Notes</small>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">@item.Quantity.ToString("G")</td>
|
<td class="text-center"><span data-inline-field="quantity" data-raw-value="@item.Quantity">@item.Quantity.ToString("G")</span></td>
|
||||||
<td class="text-end">@item.UnitPrice.ToString("C")</td>
|
<td class="text-end"><span data-inline-field="unitPrice" data-raw-value="@item.UnitPrice">@item.UnitPrice.ToString("C")</span></td>
|
||||||
<td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td>
|
<td class="text-end fw-semibold" data-line-total>@item.TotalPrice.ToString("C")</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -257,7 +257,7 @@
|
|||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<div class="d-flex justify-content-between mb-1">
|
<div class="d-flex justify-content-between mb-1">
|
||||||
<span class="text-muted">Subtotal</span>
|
<span class="text-muted">Subtotal</span>
|
||||||
<span>@Model.SubTotal.ToString("C")</span>
|
<span id="inv-subtotal">@Model.SubTotal.ToString("C")</span>
|
||||||
</div>
|
</div>
|
||||||
@if (Model.DiscountAmount > 0)
|
@if (Model.DiscountAmount > 0)
|
||||||
{
|
{
|
||||||
@@ -276,12 +276,12 @@
|
|||||||
<span class="badge bg-warning-subtle text-warning-emphasis ms-1 small fw-normal">@Model.SalesTaxAccountName</span>
|
<span class="badge bg-warning-subtle text-warning-emphasis ms-1 small fw-normal">@Model.SalesTaxAccountName</span>
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
<span>@Model.TaxAmount.ToString("C")</span>
|
<span id="inv-tax">@Model.TaxAmount.ToString("C")</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div class="d-flex justify-content-between fw-bold border-top pt-2 mt-1 fs-5">
|
<div class="d-flex justify-content-between fw-bold border-top pt-2 mt-1 fs-5">
|
||||||
<span>Total</span>
|
<span>Total</span>
|
||||||
<span>@Model.Total.ToString("C")</span>
|
<span id="inv-total">@Model.Total.ToString("C")</span>
|
||||||
</div>
|
</div>
|
||||||
@if (Model.AmountPaid > 0)
|
@if (Model.AmountPaid > 0)
|
||||||
{
|
{
|
||||||
@@ -291,7 +291,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="d-flex justify-content-between fw-bold border-top pt-2 mt-1">
|
<div class="d-flex justify-content-between fw-bold border-top pt-2 mt-1">
|
||||||
<span>Balance Due</span>
|
<span>Balance Due</span>
|
||||||
<span class="@(Model.BalanceDue > 0 ? "text-danger" : "text-success")">@Model.BalanceDue.ToString("C")</span>
|
<span id="inv-balance" class="@(Model.BalanceDue > 0 ? "text-danger" : "text-success")">@Model.BalanceDue.ToString("C")</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -1442,6 +1442,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
|
<script src="~/js/inline-item-edit.js"></script>
|
||||||
|
<script>
|
||||||
|
window.inlineItemEdit = {
|
||||||
|
patchUrl: '@Url.Action("PatchItem", "Invoices")',
|
||||||
|
canEdit: @Json.Serialize(canEdit),
|
||||||
|
totals: {
|
||||||
|
subtotal: '#inv-subtotal',
|
||||||
|
tax: '#inv-tax',
|
||||||
|
total: '#inv-total',
|
||||||
|
balance: '#inv-balance'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
<script>
|
<script>
|
||||||
function submitSendInvoice(sendEmail, sendSms) {
|
function submitSendInvoice(sendEmail, sendSms) {
|
||||||
document.getElementById('sendInvoiceSendEmail').value = sendEmail ? 'true' : 'false';
|
document.getElementById('sendInvoiceSendEmail').value = sendEmail ? 'true' : 'false';
|
||||||
|
|||||||
@@ -341,9 +341,9 @@
|
|||||||
@foreach (var item in catalogItems)
|
@foreach (var item in catalogItems)
|
||||||
{
|
{
|
||||||
var catIdx = allItems.IndexOf(item);
|
var catIdx = allItems.IndexOf(item);
|
||||||
<tr>
|
<tr data-item-id="@item.Id">
|
||||||
<td>
|
<td>
|
||||||
<strong>@item.Description</strong>
|
<span data-inline-field="description" data-raw-value="@item.Description"><strong>@item.Description</strong></span>
|
||||||
@if (item.Coats != null && item.Coats.Any())
|
@if (item.Coats != null && item.Coats.Any())
|
||||||
{
|
{
|
||||||
<br />
|
<br />
|
||||||
@@ -399,9 +399,9 @@
|
|||||||
<br /><small class="text-muted"><i class="bi bi-sticky me-1"></i>@item.Notes</small>
|
<br /><small class="text-muted"><i class="bi bi-sticky me-1"></i>@item.Notes</small>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">@item.Quantity</td>
|
<td class="text-center"><span data-inline-field="quantity" data-raw-value="@item.Quantity">@item.Quantity</span></td>
|
||||||
<td class="text-end">@item.UnitPrice.ToString("C")</td>
|
<td class="text-end"><span data-inline-field="unitPrice" data-raw-value="@item.UnitPrice">@item.UnitPrice.ToString("C")</span></td>
|
||||||
<td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td>
|
<td class="text-end fw-semibold" data-line-total>@item.TotalPrice.ToString("C")</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<div class="btn-group btn-group-sm">
|
<div class="btn-group btn-group-sm">
|
||||||
<button type="button" class="btn btn-outline-secondary" onclick="openWizard(@catIdx)" title="Edit"><i class="bi bi-pencil"></i></button>
|
<button type="button" class="btn btn-outline-secondary" onclick="openWizard(@catIdx)" title="Edit"><i class="bi bi-pencil"></i></button>
|
||||||
@@ -440,6 +440,7 @@
|
|||||||
{
|
{
|
||||||
var custIdx = allItems.IndexOf(item);
|
var custIdx = allItems.IndexOf(item);
|
||||||
// Use stored PowderToOrder per coat; fall back to calculating from efficiency data
|
// Use stored PowderToOrder per coat; fall back to calculating from efficiency data
|
||||||
|
// Note: row has data-item-id for inline editing
|
||||||
decimal totalPowderNeeded = 0;
|
decimal totalPowderNeeded = 0;
|
||||||
if (item.Coats != null && item.Coats.Any(c => c.PowderToOrder > 0))
|
if (item.Coats != null && item.Coats.Any(c => c.PowderToOrder > 0))
|
||||||
{
|
{
|
||||||
@@ -458,9 +459,9 @@
|
|||||||
{
|
{
|
||||||
totalPowderNeeded = (item.SurfaceAreaSqFt * item.Quantity) / (30m * 0.65m);
|
totalPowderNeeded = (item.SurfaceAreaSqFt * item.Quantity) / (30m * 0.65m);
|
||||||
}
|
}
|
||||||
<tr>
|
<tr data-item-id="@item.Id">
|
||||||
<td>
|
<td>
|
||||||
<strong>@item.Description</strong>
|
<span data-inline-field="description" data-raw-value="@item.Description"><strong>@item.Description</strong></span>
|
||||||
@if (item.RequiresSandblasting || item.RequiresMasking)
|
@if (item.RequiresSandblasting || item.RequiresMasking)
|
||||||
{
|
{
|
||||||
<br />
|
<br />
|
||||||
@@ -528,7 +529,7 @@
|
|||||||
<br /><small class="text-muted"><i class="bi bi-sticky me-1"></i>@item.Notes</small>
|
<br /><small class="text-muted"><i class="bi bi-sticky me-1"></i>@item.Notes</small>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">@item.Quantity</td>
|
<td class="text-center"><span data-inline-field="quantity" data-raw-value="@item.Quantity">@item.Quantity</span></td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
@if (item.SurfaceAreaSqFt > 0)
|
@if (item.SurfaceAreaSqFt > 0)
|
||||||
{
|
{
|
||||||
@@ -553,8 +554,8 @@
|
|||||||
}
|
}
|
||||||
else { <span class="text-muted">—</span> }
|
else { <span class="text-muted">—</span> }
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end">@item.UnitPrice.ToString("C")</td>
|
<td class="text-end"><span data-inline-field="unitPrice" data-raw-value="@item.UnitPrice">@item.UnitPrice.ToString("C")</span></td>
|
||||||
<td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td>
|
<td class="text-end fw-semibold" data-line-total>@item.TotalPrice.ToString("C")</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<div class="btn-group btn-group-sm">
|
<div class="btn-group btn-group-sm">
|
||||||
<button type="button" class="btn btn-outline-secondary" onclick="openWizard(@custIdx)" title="Edit"><i class="bi bi-pencil"></i></button>
|
<button type="button" class="btn btn-outline-secondary" onclick="openWizard(@custIdx)" title="Edit"><i class="bi bi-pencil"></i></button>
|
||||||
@@ -590,15 +591,15 @@
|
|||||||
@foreach (var item in laborItems)
|
@foreach (var item in laborItems)
|
||||||
{
|
{
|
||||||
var labIdx = allItems.IndexOf(item);
|
var labIdx = allItems.IndexOf(item);
|
||||||
<tr>
|
<tr data-item-id="@item.Id">
|
||||||
<td>
|
<td>
|
||||||
<strong>@item.Description</strong>
|
<span data-inline-field="description" data-raw-value="@item.Description"><strong>@item.Description</strong></span>
|
||||||
@if (!string.IsNullOrEmpty(item.Notes))
|
@if (!string.IsNullOrEmpty(item.Notes))
|
||||||
{
|
{
|
||||||
<br /><small class="text-muted"><i class="bi bi-sticky me-1"></i>@item.Notes</small>
|
<br /><small class="text-muted"><i class="bi bi-sticky me-1"></i>@item.Notes</small>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">@item.Quantity</td>
|
<td class="text-center"><span data-inline-field="quantity" data-raw-value="@item.Quantity">@item.Quantity</span></td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
@if (item.EstimatedMinutes > 0)
|
@if (item.EstimatedMinutes > 0)
|
||||||
{
|
{
|
||||||
@@ -606,8 +607,8 @@
|
|||||||
}
|
}
|
||||||
else { <span class="text-muted">—</span> }
|
else { <span class="text-muted">—</span> }
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end">@item.UnitPrice.ToString("C")</td>
|
<td class="text-end"><span data-inline-field="unitPrice" data-raw-value="@item.UnitPrice">@item.UnitPrice.ToString("C")</span></td>
|
||||||
<td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td>
|
<td class="text-end fw-semibold" data-line-total>@item.TotalPrice.ToString("C")</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<div class="btn-group btn-group-sm">
|
<div class="btn-group btn-group-sm">
|
||||||
<button type="button" class="btn btn-outline-secondary" onclick="openWizard(@labIdx)" title="Edit"><i class="bi bi-pencil"></i></button>
|
<button type="button" class="btn btn-outline-secondary" onclick="openWizard(@labIdx)" title="Edit"><i class="bi bi-pencil"></i></button>
|
||||||
@@ -1688,7 +1689,7 @@
|
|||||||
}
|
}
|
||||||
<div class="d-flex justify-content-between fw-bold border-top pt-2 mt-1">
|
<div class="d-flex justify-content-between fw-bold border-top pt-2 mt-1">
|
||||||
<span>Total</span>
|
<span>Total</span>
|
||||||
<span>@jobPb.Total.ToString("C")</span>
|
<span class="job-final-price-display">@jobPb.Total.ToString("C")</span>
|
||||||
</div>
|
</div>
|
||||||
@{
|
@{
|
||||||
var jobTotalDirectCost = jobPb.MaterialCosts + jobPb.LaborCosts + jobPb.EquipmentCosts + jobPb.OvenBatchCost + jobPb.FacilityOverheadCost + jobPb.ShopSuppliesAmount;
|
var jobTotalDirectCost = jobPb.MaterialCosts + jobPb.LaborCosts + jobPb.EquipmentCosts + jobPb.OvenBatchCost + jobPb.FacilityOverheadCost + jobPb.ShopSuppliesAmount;
|
||||||
@@ -1717,7 +1718,7 @@
|
|||||||
}
|
}
|
||||||
<div>
|
<div>
|
||||||
<label class="text-muted small mb-1">Final Price</label>
|
<label class="text-muted small mb-1">Final Price</label>
|
||||||
<h3 class="mb-0 text-primary">@Model.FinalPrice.ToString("C")</h3>
|
<h3 class="mb-0 text-primary job-final-price-display">@Model.FinalPrice.ToString("C")</h3>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -2415,6 +2416,16 @@
|
|||||||
<link rel="stylesheet" href="~/css/job-photos.css" />
|
<link rel="stylesheet" href="~/css/job-photos.css" />
|
||||||
<script src="~/js/job-photos.js" asp-append-version="true"></script>
|
<script src="~/js/job-photos.js" asp-append-version="true"></script>
|
||||||
<script src="~/js/customer-change.js" asp-append-version="true"></script>
|
<script src="~/js/customer-change.js" asp-append-version="true"></script>
|
||||||
|
<script src="~/js/inline-item-edit.js" asp-append-version="true"></script>
|
||||||
|
<script>
|
||||||
|
window.inlineItemEdit = {
|
||||||
|
patchUrl: '@Url.Action("PatchItem", "Jobs")',
|
||||||
|
canEdit: true,
|
||||||
|
totals: {
|
||||||
|
finalPrice: '.job-final-price-display'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
<script>
|
<script>
|
||||||
// -- Inline date editing ----------------------------------------------
|
// -- Inline date editing ----------------------------------------------
|
||||||
const jobId = @Model.Id;
|
const jobId = @Model.Id;
|
||||||
|
|||||||
@@ -276,14 +276,14 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var item in catalogItems)
|
@foreach (var item in catalogItems)
|
||||||
{
|
{
|
||||||
<tr>
|
<tr data-item-id="@item.Id">
|
||||||
<td>
|
<td>
|
||||||
@{
|
@{
|
||||||
var displayDescription = item.Description == "Product Item" || string.IsNullOrWhiteSpace(item.Description)
|
var displayDescription = item.Description == "Product Item" || string.IsNullOrWhiteSpace(item.Description)
|
||||||
? (item.CatalogItemName ?? "Catalog Item")
|
? (item.CatalogItemName ?? "Catalog Item")
|
||||||
: item.Description;
|
: item.Description;
|
||||||
}
|
}
|
||||||
<strong>@displayDescription</strong>
|
<span data-inline-field="description" data-raw-value="@displayDescription"><strong>@displayDescription</strong></span>
|
||||||
@if (item.CatalogItemId.HasValue &&
|
@if (item.CatalogItemId.HasValue &&
|
||||||
item.Description != "Product Item" &&
|
item.Description != "Product Item" &&
|
||||||
!string.IsNullOrWhiteSpace(item.Description))
|
!string.IsNullOrWhiteSpace(item.Description))
|
||||||
@@ -354,9 +354,9 @@
|
|||||||
<small class="text-muted"><i class="bi bi-sticky"></i> <strong>Notes:</strong> @item.Notes</small>
|
<small class="text-muted"><i class="bi bi-sticky"></i> <strong>Notes:</strong> @item.Notes</small>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td>@item.Quantity</td>
|
<td><span data-inline-field="quantity" data-raw-value="@item.Quantity">@item.Quantity</span></td>
|
||||||
<td>@item.UnitPrice.ToString("C")</td>
|
<td><span data-inline-field="unitPrice" data-raw-value="@item.UnitPrice">@item.UnitPrice.ToString("C")</span></td>
|
||||||
<td><strong>@item.TotalPrice.ToString("C")</strong></td>
|
<td data-line-total><strong>@item.TotalPrice.ToString("C")</strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -394,9 +394,9 @@
|
|||||||
var totalSqFt = item.SurfaceAreaSqFt * item.Quantity;
|
var totalSqFt = item.SurfaceAreaSqFt * item.Quantity;
|
||||||
var powderOnPart = totalSqFt / coverageRate;
|
var powderOnPart = totalSqFt / coverageRate;
|
||||||
var totalPowderNeeded = powderOnPart / transferEff;
|
var totalPowderNeeded = powderOnPart / transferEff;
|
||||||
<tr>
|
<tr data-item-id="@item.Id">
|
||||||
<td>
|
<td>
|
||||||
<strong>@item.Description</strong>
|
<span data-inline-field="description" data-raw-value="@item.Description"><strong>@item.Description</strong></span>
|
||||||
|
|
||||||
@* Display coating layers *@
|
@* Display coating layers *@
|
||||||
@if (item.Coats != null && item.Coats.Any())
|
@if (item.Coats != null && item.Coats.Any())
|
||||||
@@ -474,7 +474,7 @@
|
|||||||
<small class="text-muted"><i class="bi bi-sticky"></i> <strong>Notes:</strong> @item.Notes</small>
|
<small class="text-muted"><i class="bi bi-sticky"></i> <strong>Notes:</strong> @item.Notes</small>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td>@item.Quantity</td>
|
<td><span data-inline-field="quantity" data-raw-value="@item.Quantity">@item.Quantity</span></td>
|
||||||
<td>
|
<td>
|
||||||
@item.SurfaceAreaSqFt.ToString("F2") @ViewBag.AreaUnit
|
@item.SurfaceAreaSqFt.ToString("F2") @ViewBag.AreaUnit
|
||||||
<br /><small class="text-muted">per item</small>
|
<br /><small class="text-muted">per item</small>
|
||||||
@@ -487,8 +487,8 @@
|
|||||||
<strong class="text-success">@totalPowderNeeded.ToString("F2") lbs</strong>
|
<strong class="text-success">@totalPowderNeeded.ToString("F2") lbs</strong>
|
||||||
<br /><small class="text-muted">total batch</small>
|
<br /><small class="text-muted">total batch</small>
|
||||||
</td>
|
</td>
|
||||||
<td>@item.UnitPrice.ToString("C")</td>
|
<td><span data-inline-field="unitPrice" data-raw-value="@item.UnitPrice">@item.UnitPrice.ToString("C")</span></td>
|
||||||
<td><strong>@item.TotalPrice.ToString("C")</strong></td>
|
<td data-line-total><strong>@item.TotalPrice.ToString("C")</strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -1023,7 +1023,7 @@
|
|||||||
|
|
||||||
<div class="d-flex justify-content-between mb-2">
|
<div class="d-flex justify-content-between mb-2">
|
||||||
<span>Subtotal:</span>
|
<span>Subtotal:</span>
|
||||||
<strong>@Model.PricingBreakdown.SubtotalBeforeDiscount.ToString("C")</strong>
|
<strong id="quote-subtotal">@Model.PricingBreakdown.SubtotalBeforeDiscount.ToString("C")</strong>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (Model.PricingBreakdown.DiscountAmount > 0)
|
@if (Model.PricingBreakdown.DiscountAmount > 0)
|
||||||
@@ -1075,7 +1075,7 @@
|
|||||||
{
|
{
|
||||||
<div class="d-flex justify-content-between mb-2">
|
<div class="d-flex justify-content-between mb-2">
|
||||||
<span>Tax (@Model.PricingBreakdown.TaxPercent.ToString("G29")%):</span>
|
<span>Tax (@Model.PricingBreakdown.TaxPercent.ToString("G29")%):</span>
|
||||||
<strong>@Model.PricingBreakdown.TaxAmount.ToString("C")</strong>
|
<strong id="quote-tax">@Model.PricingBreakdown.TaxAmount.ToString("C")</strong>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1083,7 +1083,7 @@
|
|||||||
|
|
||||||
<div class="d-flex justify-content-between mb-0">
|
<div class="d-flex justify-content-between mb-0">
|
||||||
<h5>Total:</h5>
|
<h5>Total:</h5>
|
||||||
<h5 class="text-primary"><strong>@Model.Total.ToString("C")</strong></h5>
|
<h5 class="text-primary"><strong id="quote-total">@Model.Total.ToString("C")</strong></h5>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (Model.PricingBreakdown.DiscountAmount > 0)
|
@if (Model.PricingBreakdown.DiscountAmount > 0)
|
||||||
@@ -2262,6 +2262,18 @@
|
|||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
<script src="~/js/customer-change.js" asp-append-version="true"></script>
|
<script src="~/js/customer-change.js" asp-append-version="true"></script>
|
||||||
|
<script src="~/js/inline-item-edit.js" asp-append-version="true"></script>
|
||||||
|
<script>
|
||||||
|
window.inlineItemEdit = {
|
||||||
|
patchUrl: '@Url.Action("PatchItem", "Quotes")',
|
||||||
|
canEdit: true,
|
||||||
|
totals: {
|
||||||
|
subtotal: '#quote-subtotal',
|
||||||
|
tax: '#quote-tax',
|
||||||
|
total: '#quote-total'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
<script>
|
<script>
|
||||||
function sendQuoteToAdHocEmail(quoteId) {
|
function sendQuoteToAdHocEmail(quoteId) {
|
||||||
const email = (document.getElementById('quoteAdHocEmailInput').value ?? '').trim();
|
const email = (document.getElementById('quoteAdHocEmailInput').value ?? '').trim();
|
||||||
|
|||||||
@@ -1152,3 +1152,14 @@ a.tag-index-badge:hover {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.mw-lg { max-width: 640px; }
|
.mw-lg { max-width: 640px; }
|
||||||
|
|
||||||
|
/* ── Inline item edit ───────────────────────────────────────── */
|
||||||
|
.inline-editable:hover {
|
||||||
|
text-decoration: underline dotted;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
.inline-edit-input {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 4px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/// <summary>
|
||||||
|
/// Shared inline-edit behaviour for quote, job, and invoice item rows.
|
||||||
|
/// Activated when the page sets window.inlineItemEdit = { patchUrl, canEdit, totals }.
|
||||||
|
/// totals: { subtotal, tax, total, finalPrice, balance } — CSS selectors, any subset.
|
||||||
|
/// </summary>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const cfg = window.inlineItemEdit;
|
||||||
|
if (!cfg || !cfg.canEdit) return;
|
||||||
|
|
||||||
|
function fmt(val) {
|
||||||
|
return val.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function csrfToken() {
|
||||||
|
return document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'alert alert-danger alert-permanent position-fixed bottom-0 end-0 m-3 shadow';
|
||||||
|
el.style.zIndex = '9999';
|
||||||
|
el.textContent = msg;
|
||||||
|
document.body.appendChild(el);
|
||||||
|
setTimeout(() => el.remove(), 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTotals(data) {
|
||||||
|
const t = cfg.totals || {};
|
||||||
|
[
|
||||||
|
[t.subtotal, data.subtotal],
|
||||||
|
[t.tax, data.taxAmount],
|
||||||
|
[t.total, data.total],
|
||||||
|
[t.finalPrice, data.finalPrice],
|
||||||
|
[t.balance, data.balanceDue],
|
||||||
|
].forEach(([sel, val]) => {
|
||||||
|
if (sel && val !== undefined && val !== null) {
|
||||||
|
document.querySelectorAll(sel).forEach(el => { el.textContent = fmt(val); });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEditable(span) {
|
||||||
|
const field = span.dataset.inlineField;
|
||||||
|
const row = span.closest('tr[data-item-id]');
|
||||||
|
if (!row) return;
|
||||||
|
const itemId = row.dataset.itemId;
|
||||||
|
|
||||||
|
const rawVal = span.dataset.rawValue ?? span.textContent.trim();
|
||||||
|
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.className = 'form-control form-control-sm inline-edit-input';
|
||||||
|
if (field === 'description') {
|
||||||
|
input.type = 'text';
|
||||||
|
input.style.minWidth = '140px';
|
||||||
|
} else {
|
||||||
|
input.type = 'number';
|
||||||
|
input.step = '0.01';
|
||||||
|
input.min = '0';
|
||||||
|
input.style.width = '80px';
|
||||||
|
}
|
||||||
|
input.value = rawVal;
|
||||||
|
|
||||||
|
// Stash current rendered markup so we can revert
|
||||||
|
const savedHTML = span.innerHTML;
|
||||||
|
span.innerHTML = '';
|
||||||
|
span.appendChild(input);
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
|
||||||
|
let committed = false;
|
||||||
|
|
||||||
|
function revert() {
|
||||||
|
span.innerHTML = savedHTML;
|
||||||
|
attachListeners(span);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commit() {
|
||||||
|
if (committed) return;
|
||||||
|
committed = true;
|
||||||
|
|
||||||
|
const newVal = input.value.trim();
|
||||||
|
if (newVal === '' || (field !== 'description' && isNaN(parseFloat(newVal)))) {
|
||||||
|
revert();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read sibling raw values from other editable cells in the same row
|
||||||
|
function siblingRaw(f) {
|
||||||
|
const s = row.querySelector(`[data-inline-field="${f}"]`);
|
||||||
|
if (!s) return null;
|
||||||
|
// If that sibling is currently showing an input (concurrent edit, unlikely), fall back
|
||||||
|
const inp = s.querySelector('input.inline-edit-input');
|
||||||
|
if (inp) return inp.value;
|
||||||
|
return s.dataset.rawValue ?? s.textContent.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const description = field === 'description' ? newVal : (siblingRaw('description') ?? '');
|
||||||
|
const quantity = parseFloat(field === 'quantity' ? newVal : (siblingRaw('quantity') ?? '1'));
|
||||||
|
const unitPrice = parseFloat(field === 'unitPrice' ? newVal : (siblingRaw('unitPrice') ?? '0'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(cfg.patchUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'RequestVerificationToken': csrfToken()
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ itemId: parseInt(itemId, 10), description, quantity, unitPrice })
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
showError(err.error ?? 'Could not save — try again.');
|
||||||
|
revert();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
// Update this span's display and stored raw value
|
||||||
|
if (field === 'description') {
|
||||||
|
const strong = span.querySelector('strong');
|
||||||
|
if (strong) { strong.textContent = newVal; }
|
||||||
|
else { span.innerHTML = `<strong>${newVal}</strong>`; }
|
||||||
|
span.dataset.rawValue = newVal;
|
||||||
|
} else if (field === 'quantity') {
|
||||||
|
span.dataset.rawValue = quantity;
|
||||||
|
span.textContent = quantity % 1 === 0 ? quantity.toFixed(0) : quantity.toString();
|
||||||
|
} else if (field === 'unitPrice') {
|
||||||
|
span.dataset.rawValue = unitPrice;
|
||||||
|
span.textContent = fmt(unitPrice);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update line total cell
|
||||||
|
const totalCell = row.querySelector('[data-line-total]');
|
||||||
|
if (totalCell) totalCell.textContent = fmt(data.lineTotal);
|
||||||
|
|
||||||
|
// Update document-level totals
|
||||||
|
updateTotals(data);
|
||||||
|
|
||||||
|
// Re-attach click listener for next edit
|
||||||
|
attachListeners(span);
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
showError('Could not save — check your connection and try again.');
|
||||||
|
revert();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener('blur', commit);
|
||||||
|
input.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
|
||||||
|
if (e.key === 'Escape') { committed = true; revert(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachListeners(span) {
|
||||||
|
span.style.cursor = 'text';
|
||||||
|
span.title = 'Click to edit';
|
||||||
|
span.classList.add('inline-editable');
|
||||||
|
span.addEventListener('click', () => makeEditable(span), { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
document.querySelectorAll('[data-inline-field]').forEach(attachListeners);
|
||||||
|
});
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user