Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 81dc34bab4 | |||
| b9e9449c8b | |||
| fd38785942 | |||
| 33277de727 | |||
| 4ac62551f4 | |||
| 7fa385aeb8 | |||
| 8452ea3fcd | |||
| 9b34ff564e | |||
| 24f3df1bbc | |||
| 551116d7e5 |
@@ -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>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -355,6 +355,15 @@ public class InvoicesController : Controller
|
|||||||
var job = await _unitOfWork.Jobs.GetByIdAsync(jobId.Value, false, j => j.Customer, j => j.JobItems);
|
var job = await _unitOfWork.Jobs.GetByIdAsync(jobId.Value, false, j => j.Customer, j => j.JobItems);
|
||||||
if (job == null) return NotFound();
|
if (job == null) return NotFound();
|
||||||
|
|
||||||
|
// Pre-load coats so we can derive color names for invoice line items
|
||||||
|
var activeItemIds = job.JobItems.Where(ji => !ji.IsDeleted).Select(ji => ji.Id).ToList();
|
||||||
|
var allCoats = activeItemIds.Any()
|
||||||
|
? (await _unitOfWork.JobItemCoats.FindAsync(c => activeItemIds.Contains(c.JobItemId) && !c.IsDeleted)).ToList()
|
||||||
|
: new List<JobItemCoat>();
|
||||||
|
var coatsByItem = allCoats
|
||||||
|
.GroupBy(c => c.JobItemId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.OrderBy(c => c.Sequence).ToList());
|
||||||
|
|
||||||
// Validate no existing active invoice for this job (voided ones are kept as history)
|
// Validate no existing active invoice for this job (voided ones are kept as history)
|
||||||
var existing = await _unitOfWork.Invoices.GetForJobAsync(jobId.Value);
|
var existing = await _unitOfWork.Invoices.GetForJobAsync(jobId.Value);
|
||||||
if (existing != null && existing.Status != InvoiceStatus.Voided)
|
if (existing != null && existing.Status != InvoiceStatus.Voided)
|
||||||
@@ -404,6 +413,16 @@ public class InvoicesController : Controller
|
|||||||
revenueAccountId = ci.RevenueAccountId;
|
revenueAccountId = ci.RevenueAccountId;
|
||||||
revenueAccountId ??= defaultRevenueAccount?.Id;
|
revenueAccountId ??= defaultRevenueAccount?.Id;
|
||||||
|
|
||||||
|
// Derive color from coats when the item itself has no explicit color set
|
||||||
|
var derivedColor = item.ColorName;
|
||||||
|
if (string.IsNullOrEmpty(derivedColor) && coatsByItem.TryGetValue(item.Id, out var itemCoats))
|
||||||
|
{
|
||||||
|
var coatColors = itemCoats
|
||||||
|
.Where(c => !string.IsNullOrEmpty(c.ColorName))
|
||||||
|
.Select(c => c.ColorName!);
|
||||||
|
derivedColor = string.Join(" / ", coatColors);
|
||||||
|
}
|
||||||
|
|
||||||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||||
{
|
{
|
||||||
SourceJobItemId = item.Id,
|
SourceJobItemId = item.Id,
|
||||||
@@ -412,7 +431,7 @@ public class InvoicesController : Controller
|
|||||||
Quantity = item.Quantity > 0 ? item.Quantity : 1,
|
Quantity = item.Quantity > 0 ? item.Quantity : 1,
|
||||||
UnitPrice = item.UnitPrice,
|
UnitPrice = item.UnitPrice,
|
||||||
TotalPrice = item.TotalPrice,
|
TotalPrice = item.TotalPrice,
|
||||||
ColorName = item.ColorName,
|
ColorName = derivedColor,
|
||||||
Notes = item.Notes,
|
Notes = item.Notes,
|
||||||
DisplayOrder = order++,
|
DisplayOrder = order++,
|
||||||
RevenueAccountId = revenueAccountId
|
RevenueAccountId = revenueAccountId
|
||||||
@@ -1461,6 +1480,7 @@ public class InvoicesController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
var currentUser = await _userManager.GetUserAsync(User);
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
|
var totalCreditCreated = 0m; // populated inside transaction, used in success message
|
||||||
|
|
||||||
await _unitOfWork.ExecuteInTransactionAsync(async () =>
|
await _unitOfWork.ExecuteInTransactionAsync(async () =>
|
||||||
{
|
{
|
||||||
@@ -1480,6 +1500,75 @@ public class InvoicesController : Controller
|
|||||||
await _unitOfWork.Payments.SoftDeleteAsync(payment.Id);
|
await _unitOfWork.Payments.SoftDeleteAsync(payment.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-release any deposits that were applied to this invoice so they can be
|
||||||
|
// auto-applied to the replacement invoice. Without this, AppliedToInvoiceId
|
||||||
|
// stays set and the deposit lookup (AppliedToInvoiceId == null) skips them.
|
||||||
|
var appliedDeposits = await _unitOfWork.Deposits.FindAsync(
|
||||||
|
d => d.AppliedToInvoiceId == invoice.Id && !d.IsDeleted);
|
||||||
|
var totalDepositReleased = 0m;
|
||||||
|
foreach (var deposit in appliedDeposits)
|
||||||
|
{
|
||||||
|
deposit.AppliedToInvoiceId = null;
|
||||||
|
deposit.AppliedDate = null;
|
||||||
|
deposit.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _unitOfWork.Deposits.UpdateAsync(deposit);
|
||||||
|
totalDepositReleased += deposit.Amount;
|
||||||
|
}
|
||||||
|
// Restore the CustomerDeposits 2300 liability that was cleared when the deposits
|
||||||
|
// were applied. Mirrors the DR at apply time; follows the same simplified reversal
|
||||||
|
// pattern as the rest of the void (regular payment GL entries are also left as-is).
|
||||||
|
if (totalDepositReleased > 0)
|
||||||
|
{
|
||||||
|
var custDepositsAcctId = await GetCustomerDepositsAccountIdAsync(invoice.CompanyId);
|
||||||
|
await _accountBalanceService.CreditAsync(custDepositsAcctId, totalDepositReleased);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert non-deposit payments (cash, card, check, online) to customer credits so
|
||||||
|
// the money isn't lost when the invoice is voided. Each payment becomes a CRED-
|
||||||
|
// Deposit record linked to the same job; it will auto-apply when the replacement
|
||||||
|
// invoice is created, exactly like a normal deposit.
|
||||||
|
var nonDepositPayments = invoice.Payments
|
||||||
|
.Where(p => !p.IsDeleted && !(p.Reference ?? "").StartsWith("Deposit "))
|
||||||
|
.ToList();
|
||||||
|
if (nonDepositPayments.Any())
|
||||||
|
{
|
||||||
|
var credPrefix = $"CRED-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-";
|
||||||
|
var existingNums = (await _unitOfWork.Deposits.FindAsync(
|
||||||
|
d => d.CompanyId == invoice.CompanyId && d.ReceiptNumber.StartsWith(credPrefix),
|
||||||
|
ignoreQueryFilters: true))
|
||||||
|
.Select(d => d.ReceiptNumber).ToList();
|
||||||
|
var maxNum = 0;
|
||||||
|
foreach (var rn in existingNums)
|
||||||
|
{
|
||||||
|
var suffix = rn.Length >= credPrefix.Length + 4 ? rn[credPrefix.Length..] : "";
|
||||||
|
if (int.TryParse(suffix, out int parsed) && parsed > maxNum) maxNum = parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
var creditCustDepositsAcctId = await GetCustomerDepositsAccountIdAsync(invoice.CompanyId);
|
||||||
|
foreach (var payment in nonDepositPayments)
|
||||||
|
{
|
||||||
|
maxNum++;
|
||||||
|
await _unitOfWork.Deposits.AddAsync(new Core.Entities.Deposit
|
||||||
|
{
|
||||||
|
CompanyId = invoice.CompanyId,
|
||||||
|
CustomerId = invoice.CustomerId,
|
||||||
|
JobId = invoice.JobId,
|
||||||
|
Amount = payment.Amount,
|
||||||
|
PaymentMethod = payment.PaymentMethod,
|
||||||
|
ReceivedDate = payment.PaymentDate,
|
||||||
|
Reference = payment.Reference,
|
||||||
|
Notes = $"Credit from voided invoice {invoice.InvoiceNumber}" +
|
||||||
|
(string.IsNullOrWhiteSpace(payment.Notes) ? "." : $". Original: {payment.Notes}"),
|
||||||
|
ReceiptNumber = $"{credPrefix}{maxNum:D4}",
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
totalCreditCreated += payment.Amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CR CustomerDeposits to create the liability matching the cash already in Checking
|
||||||
|
await _accountBalanceService.CreditAsync(creditCustDepositsAcctId, totalCreditCreated);
|
||||||
|
}
|
||||||
|
|
||||||
// Void any gift certificates that were generated from this invoice.
|
// Void any gift certificates that were generated from this invoice.
|
||||||
// Capture each GC's remaining balance BEFORE voiding so the GL entries below can use it.
|
// Capture each GC's remaining balance BEFORE voiding so the GL entries below can use it.
|
||||||
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(invoice.CompanyId);
|
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(invoice.CompanyId);
|
||||||
@@ -1531,7 +1620,10 @@ public class InvoicesController : Controller
|
|||||||
|
|
||||||
}); // end ExecuteInTransactionAsync
|
}); // end ExecuteInTransactionAsync
|
||||||
|
|
||||||
TempData["Success"] = $"Invoice {invoice.InvoiceNumber} has been voided.";
|
var creditMsg = totalCreditCreated > 0
|
||||||
|
? $" {totalCreditCreated:C} converted to customer credit and will auto-apply to the next invoice."
|
||||||
|
: "";
|
||||||
|
TempData["Success"] = $"Invoice {invoice.InvoiceNumber} has been voided.{creditMsg}";
|
||||||
return RedirectToAction(nameof(Details), new { id });
|
return RedirectToAction(nameof(Details), new { id });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -3016,6 +3108,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).
|
||||||
@@ -3031,3 +3167,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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -110,6 +110,11 @@ public class JobsController : Controller
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Default landing view: On Floor — redirect bare /Jobs to ?statusGroup=active
|
||||||
|
// so completed/cancelled jobs don't clutter the first screen.
|
||||||
|
if (string.IsNullOrEmpty(statusGroup) && string.IsNullOrEmpty(searchTerm) && string.IsNullOrEmpty(tagFilter))
|
||||||
|
return RedirectToAction("Index", new { statusGroup = "active" });
|
||||||
|
|
||||||
// Create and validate grid request
|
// Create and validate grid request
|
||||||
var gridRequest = new GridRequest
|
var gridRequest = new GridRequest
|
||||||
{
|
{
|
||||||
@@ -141,6 +146,13 @@ public class JobsController : Controller
|
|||||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered
|
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered
|
||||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled;
|
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled;
|
||||||
}
|
}
|
||||||
|
else if (statusGroup == "completed")
|
||||||
|
{
|
||||||
|
filter = j => j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Completed
|
||||||
|
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup
|
||||||
|
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Delivered;
|
||||||
|
}
|
||||||
|
// "all" or unknown group: no filter applied (show every status)
|
||||||
}
|
}
|
||||||
else if (!string.IsNullOrWhiteSpace(searchTerm))
|
else if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||||
{
|
{
|
||||||
@@ -195,6 +207,27 @@ public class JobsController : Controller
|
|||||||
gridRequest, jobDtos,
|
gridRequest, jobDtos,
|
||||||
string.IsNullOrWhiteSpace(tagFilter) ? totalCount : jobDtos.Count);
|
string.IsNullOrWhiteSpace(tagFilter) ? totalCount : jobDtos.Count);
|
||||||
|
|
||||||
|
// Pill badge counts — always global (not scoped to current filter/page)
|
||||||
|
var today = DateTime.Today;
|
||||||
|
ViewBag.AllJobCount = await _unitOfWork.Jobs.CountAsync();
|
||||||
|
ViewBag.ActiveCount = await _unitOfWork.Jobs.CountAsync(j =>
|
||||||
|
j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Completed
|
||||||
|
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.ReadyForPickup
|
||||||
|
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered
|
||||||
|
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled);
|
||||||
|
ViewBag.OverdueCount = await _unitOfWork.Jobs.CountAsync(j =>
|
||||||
|
j.DueDate < today
|
||||||
|
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Completed
|
||||||
|
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.ReadyForPickup
|
||||||
|
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered
|
||||||
|
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled);
|
||||||
|
ViewBag.CompletedCount = await _unitOfWork.Jobs.CountAsync(j =>
|
||||||
|
j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Completed
|
||||||
|
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup
|
||||||
|
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Delivered);
|
||||||
|
ViewBag.ReadyCount = await _unitOfWork.Jobs.CountAsync(j =>
|
||||||
|
j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup);
|
||||||
|
|
||||||
// Set ViewBag for sorting
|
// Set ViewBag for sorting
|
||||||
ViewBag.SearchTerm = searchTerm;
|
ViewBag.SearchTerm = searchTerm;
|
||||||
ViewBag.StatusGroup = statusGroup;
|
ViewBag.StatusGroup = statusGroup;
|
||||||
@@ -4183,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; }
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ public class PaymentController : Controller
|
|||||||
CustomerName = customer != null
|
CustomerName = customer != null
|
||||||
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
|
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
|
||||||
: "Valued Customer",
|
: "Valued Customer",
|
||||||
CustomerEmail = customer?.Email ?? string.Empty,
|
|
||||||
CompanyName = company!.CompanyName,
|
CompanyName = company!.CompanyName,
|
||||||
BalanceDue = invoice.BalanceDue,
|
BalanceDue = invoice.BalanceDue,
|
||||||
InvoiceTotal = invoice.Total,
|
InvoiceTotal = invoice.Total,
|
||||||
@@ -127,8 +126,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 +133,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);
|
||||||
|
|
||||||
@@ -261,7 +257,6 @@ public class PaymentController : Controller
|
|||||||
CustomerName = customer != null
|
CustomerName = customer != null
|
||||||
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
|
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
|
||||||
: quote.ProspectContactName ?? "Valued Customer",
|
: quote.ProspectContactName ?? "Valued Customer",
|
||||||
CustomerEmail = customer?.Email ?? quote.ProspectEmail ?? string.Empty,
|
|
||||||
CompanyName = company!.CompanyName,
|
CompanyName = company!.CompanyName,
|
||||||
DepositAmount = depositAmount,
|
DepositAmount = depositAmount,
|
||||||
QuoteTotal = quote.Total,
|
QuoteTotal = quote.Total,
|
||||||
@@ -296,7 +291,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 +298,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);
|
||||||
|
|
||||||
@@ -942,7 +935,6 @@ public class PaymentPageViewModel
|
|||||||
public int InvoiceId { get; set; }
|
public int InvoiceId { get; set; }
|
||||||
public string Token { get; set; } = string.Empty;
|
public string Token { get; set; } = string.Empty;
|
||||||
public string CustomerName { get; set; } = string.Empty;
|
public string CustomerName { get; set; } = string.Empty;
|
||||||
public string CustomerEmail { get; set; } = string.Empty;
|
|
||||||
public string CompanyName { get; set; } = string.Empty;
|
public string CompanyName { get; set; } = string.Empty;
|
||||||
public decimal BalanceDue { get; set; }
|
public decimal BalanceDue { get; set; }
|
||||||
public decimal InvoiceTotal { get; set; }
|
public decimal InvoiceTotal { get; set; }
|
||||||
@@ -963,7 +955,6 @@ public class DepositPaymentPageViewModel
|
|||||||
public int QuoteId { get; set; }
|
public int QuoteId { get; set; }
|
||||||
public string Token { get; set; } = string.Empty;
|
public string Token { get; set; } = string.Empty;
|
||||||
public string CustomerName { get; set; } = string.Empty;
|
public string CustomerName { get; set; } = string.Empty;
|
||||||
public string CustomerEmail { get; set; } = string.Empty;
|
|
||||||
public string CompanyName { get; set; } = string.Empty;
|
public string CompanyName { get; set; } = string.Empty;
|
||||||
public decimal DepositAmount { get; set; }
|
public decimal DepositAmount { get; set; }
|
||||||
public decimal QuoteTotal { get; set; }
|
public decimal QuoteTotal { get; set; }
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -265,12 +265,15 @@ public static class HelpKnowledgeBase
|
|||||||
**Job Priorities (color-coded):**
|
**Job Priorities (color-coded):**
|
||||||
- Low (grey), Normal (blue), High (orange), Urgent (red), Rush (purple)
|
- Low (grey), Normal (blue), High (orange), Urgent (red), Rush (purple)
|
||||||
|
|
||||||
|
**Jobs list default view:** The Jobs list opens on the **On Floor** filter by default — showing only active jobs (excludes Completed, Ready for Pickup, Delivered, Cancelled). Use the filter pills at the top to switch views: **All** shows every job regardless of status; **On Floor** shows active work; **Overdue** shows past-due active jobs; **Ready** shows jobs awaiting customer pickup; **Completed** shows all finished jobs (Completed + Ready for Pickup + Delivered). Each pill shows a live global count.
|
||||||
|
|
||||||
**How to create a job:**
|
**How to create a job:**
|
||||||
1. Go to [Jobs](/Jobs) → "New Job"
|
1. Go to [Jobs](/Jobs) → "New Job"
|
||||||
2. Select customer
|
2. Select customer
|
||||||
3. Add line items (same wizard as quotes: Calculated, Custom Work, or AI Photo)
|
3. Add line items (same wizard as quotes: Calculated, Custom Work, or AI Photo)
|
||||||
4. Set priority, due date, assigned worker, special instructions
|
4. Set priority, due date, assigned worker, special instructions
|
||||||
5. Save
|
5. Optionally set Oven & Batch Settings — select a named oven, number of batches, and cycle time. These affect the oven cost in pricing.
|
||||||
|
6. Save
|
||||||
|
|
||||||
**Job Priority Board:** [/JobsPriority](/JobsPriority) — Kanban-style view of all active jobs sorted by priority and status.
|
**Job Priority Board:** [/JobsPriority](/JobsPriority) — Kanban-style view of all active jobs sorted by priority and status.
|
||||||
|
|
||||||
@@ -302,7 +305,9 @@ 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.
|
**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.
|
||||||
|
|
||||||
**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. 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.
|
**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.
|
||||||
|
|
||||||
**Work Order QR Codes:** Every printed job work order includes two tiers of QR codes — one for viewing the job, and a separate set for taking action on it. All QR codes require the worker to be logged in.
|
**Work Order QR Codes:** Every printed job work order includes two tiers of QR codes — one for viewing the job, and a separate set for taking action on it. All QR codes require the worker to be logged in.
|
||||||
|
|
||||||
|
|||||||
@@ -653,6 +653,11 @@ app.Use(async (context, next) =>
|
|||||||
context.Response.Headers.Append("Permissions-Policy",
|
context.Response.Headers.Append("Permissions-Policy",
|
||||||
"geolocation=(), microphone=(), camera=()");
|
"geolocation=(), microphone=(), camera=()");
|
||||||
|
|
||||||
|
// Prevent browsers from caching authenticated pages — avoids stale data and
|
||||||
|
// browser-specific cache corruption bugs (e.g. Firefox caching a partial load).
|
||||||
|
if (context.User.Identity?.IsAuthenticated == true)
|
||||||
|
context.Response.Headers.Append("Cache-Control", "no-store");
|
||||||
|
|
||||||
await next();
|
await next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,7 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<span class="text-muted">Not set — invoices go to contact email</span>
|
<span class=”text-muted”>Not set — invoices go to contact email</span>
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,9 +25,13 @@
|
|||||||
a priority level, a due date, and one or more line items describing the work to be performed.
|
a priority level, a due date, and one or more line items describing the work to be performed.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
You can find Jobs under <strong>Operations › Jobs</strong> in the left sidebar. The list is
|
You can find Jobs under <strong>Operations › Jobs</strong> in the left sidebar. The list
|
||||||
searchable and sortable by job number, status, priority, scheduled date, due date, and price. Jobs
|
opens on the <strong>On Floor</strong> filter by default, showing only active in-progress work
|
||||||
can be created manually or converted automatically from an approved quote — no need to re-enter
|
so completed jobs don’t clutter the screen. Use the filter pills at the top to switch
|
||||||
|
views: <strong>All</strong>, <strong>On Floor</strong>, <strong>Overdue</strong>,
|
||||||
|
<strong>Ready</strong> (awaiting pickup), and <strong>Completed</strong>. The list is sortable
|
||||||
|
and searchable by job number, status, priority, scheduled date, due date, and price. Jobs can be
|
||||||
|
created manually or converted automatically from an approved quote — no need to re-enter
|
||||||
information that is already in the system.
|
information that is already in the system.
|
||||||
</p>
|
</p>
|
||||||
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
|
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
|
||||||
@@ -54,6 +58,11 @@
|
|||||||
<li class="mb-2">Enter the customer's <strong>PO Number</strong> if they require one for their own records.</li>
|
<li class="mb-2">Enter the customer's <strong>PO Number</strong> if they require one for their own records.</li>
|
||||||
<li class="mb-2">Add any <strong>Special Instructions</strong> your team needs to know before starting work.</li>
|
<li class="mb-2">Add any <strong>Special Instructions</strong> your team needs to know before starting work.</li>
|
||||||
<li class="mb-2">Add one or more <strong>Line Items</strong> describing each piece being coated. See the Job Items section below.</li>
|
<li class="mb-2">Add one or more <strong>Line Items</strong> describing each piece being coated. See the Job Items section below.</li>
|
||||||
|
<li class="mb-2">
|
||||||
|
Optionally expand <strong>Oven & Batch Settings</strong> to assign a named oven, set the
|
||||||
|
number of batches, and enter the cure cycle time. These values feed directly into the oven cost
|
||||||
|
calculation so the job’s pricing reflects the actual oven run.
|
||||||
|
</li>
|
||||||
<li class="mb-2">Click <strong>Save Job</strong>.</li>
|
<li class="mb-2">Click <strong>Save Job</strong>.</li>
|
||||||
</ol>
|
</ol>
|
||||||
<p>
|
<p>
|
||||||
@@ -353,7 +362,7 @@
|
|||||||
<li>If an invoice already exists, you will see a link to open it.</li>
|
<li>If an invoice already exists, you will see a link to open it.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="mb-2">Click <strong>Create Invoice</strong>. The system generates a new invoice pre-filled with all the job's line items and the final pricing.</li>
|
<li class="mb-2">Click <strong>Create Invoice</strong>. The system generates a new invoice pre-filled with all the job’s line items and the final pricing. Line item descriptions include the coat color(s) for each item (e.g., <em>Gloss Black / Satin Clear</em>), which helps customers distinguish repeated items such as multiple sets of calipers.</li>
|
||||||
<li class="mb-2">Review the invoice, confirm the due date, and save it.</li>
|
<li class="mb-2">Review the invoice, confirm the due date, and save it.</li>
|
||||||
</ol>
|
</ol>
|
||||||
<p>
|
<p>
|
||||||
@@ -370,6 +379,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="completing-a-job" class="mb-5">
|
||||||
|
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||||
|
<i class="bi bi-check-circle text-primary me-2"></i>Completing a Job
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
When work is done and the parts have passed quality check, click the <strong>Complete Job</strong>
|
||||||
|
button on the Job Details page. A modal opens where you confirm:
|
||||||
|
</p>
|
||||||
|
<ul class="mb-3">
|
||||||
|
<li><strong>Completion date</strong> — defaults to today.</li>
|
||||||
|
<li><strong>Actual hours spent</strong> on the job.</li>
|
||||||
|
<li><strong>Final price</strong> — pre-filled from the job; adjust if needed.</li>
|
||||||
|
<li>
|
||||||
|
<strong>Powder usage</strong> — if the job used powder from inventory, you are asked
|
||||||
|
to enter actual lbs used. The modal groups all coats by <em>unique powder color</em> and
|
||||||
|
shows one input row per powder, regardless of how many items or coats used that color.
|
||||||
|
This avoids entering the same number repeatedly. Any lbs already scanned via QR code on
|
||||||
|
the shop floor are credited automatically — you only enter the remaining amount.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Once confirmed, the job advances to <span class="badge bg-success">Completed</span> status,
|
||||||
|
inventory is updated, and you are prompted to create an invoice if one does not already exist.
|
||||||
|
</p>
|
||||||
|
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
|
||||||
|
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
|
||||||
|
<div>
|
||||||
|
Shop floor workers who complete a job via QR scan bypass the modal — the SMS
|
||||||
|
notification is sent immediately using the configured template. Managers and admins
|
||||||
|
get the full modal with a compose step before the SMS goes out.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="photos-notes" class="mb-5">
|
<section id="photos-notes" class="mb-5">
|
||||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||||
<i class="bi bi-camera text-primary me-2"></i>Photos and Notes
|
<i class="bi bi-camera text-primary me-2"></i>Photos and Notes
|
||||||
@@ -699,6 +742,7 @@
|
|||||||
<a class="nav-link py-1 px-3 small text-body" href="#job-items">Job Items</a>
|
<a class="nav-link py-1 px-3 small text-body" href="#job-items">Job Items</a>
|
||||||
<a class="nav-link py-1 px-3 small text-body" href="#converting-from-quote">Converting from a Quote</a>
|
<a class="nav-link py-1 px-3 small text-body" href="#converting-from-quote">Converting from a Quote</a>
|
||||||
<a class="nav-link py-1 px-3 small text-body" href="#creating-an-invoice">Creating an Invoice</a>
|
<a class="nav-link py-1 px-3 small text-body" href="#creating-an-invoice">Creating an Invoice</a>
|
||||||
|
<a class="nav-link py-1 px-3 small text-body" href="#completing-a-job">Completing a Job</a>
|
||||||
<a class="nav-link py-1 px-3 small text-body" href="#photos-notes">Photos and Notes</a>
|
<a class="nav-link py-1 px-3 small text-body" href="#photos-notes">Photos and Notes</a>
|
||||||
<a class="nav-link py-1 px-3 small text-body" href="#time-and-rework">Time Entries and Rework</a>
|
<a class="nav-link py-1 px-3 small text-body" href="#time-and-rework">Time Entries and Rework</a>
|
||||||
<a class="nav-link py-1 px-3 small text-body" href="#job-templates">Job Templates</a>
|
<a class="nav-link py-1 px-3 small text-body" href="#job-templates">Job Templates</a>
|
||||||
|
|||||||
@@ -28,6 +28,9 @@
|
|||||||
var onlinePaymentsEnabled = ViewBag.OnlinePaymentsEnabled == true;
|
var onlinePaymentsEnabled = ViewBag.OnlinePaymentsEnabled == true;
|
||||||
var showOnlinePaymentCard = !isDraft && !isVoided && Model.BalanceDue > 0 && onlinePaymentsEnabled;
|
var showOnlinePaymentCard = !isDraft && !isVoided && Model.BalanceDue > 0 && onlinePaymentsEnabled;
|
||||||
var guidedActivationCallout = ViewBag.GuidedActivationCallout as PowderCoating.Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel;
|
var guidedActivationCallout = ViewBag.GuidedActivationCallout as PowderCoating.Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel;
|
||||||
|
var nonDepositPaymentTotal = Model.Payments
|
||||||
|
.Where(p => !(p.Reference ?? "").StartsWith("Deposit "))
|
||||||
|
.Sum(p => p.Amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
@@ -230,21 +233,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 +260,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 +279,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 +294,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>
|
||||||
@@ -406,7 +409,7 @@
|
|||||||
@if (!isVoided)
|
@if (!isVoided)
|
||||||
{
|
{
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary me-1" title="Edit payment"
|
<button type="button" class="btn btn-sm btn-outline-secondary me-1" title="Edit payment"
|
||||||
onclick="openEditPaymentModal(@p.Id, @Model.Id, '@p.PaymentDate.ToString("yyyy-MM-dd")', @((int)p.PaymentMethod), '@(p.Reference ?? "")', '@(p.Notes ?? "")', @(p.DepositAccountId?.ToString() ?? "null"))">
|
onclick="openEditPaymentModal(@p.Id, @Model.Id, '@p.PaymentDate.ToString("yyyy-MM-dd")', @((int)p.PaymentMethod), @Json.Serialize(p.Reference ?? ""), @Json.Serialize(p.Notes ?? ""), @(p.DepositAccountId?.ToString() ?? "null"))">
|
||||||
<i class="bi bi-pencil"></i>
|
<i class="bi bi-pencil"></i>
|
||||||
</button>
|
</button>
|
||||||
<form asp-action="DeletePayment" asp-route-invoiceId="@Model.Id" asp-route-paymentId="@p.Id"
|
<form asp-action="DeletePayment" asp-route-invoiceId="@Model.Id" asp-route-paymentId="@p.Id"
|
||||||
@@ -694,7 +697,9 @@
|
|||||||
@if (!isVoided && Model.Status != InvoiceStatus.Paid)
|
@if (!isVoided && Model.Status != InvoiceStatus.Paid)
|
||||||
{
|
{
|
||||||
<form asp-action="Void" asp-route-id="@Model.Id" method="post"
|
<form asp-action="Void" asp-route-id="@Model.Id" method="post"
|
||||||
onsubmit="return confirm('Void this invoice? This will reverse the remaining balance on the customer account.')">
|
onsubmit="return confirm('@(nonDepositPaymentTotal > 0
|
||||||
|
? $"Void this invoice? {nonDepositPaymentTotal.ToString("C")} in payments will be converted to a customer credit and auto-applied to the next invoice."
|
||||||
|
: "Void this invoice? This will reverse the remaining balance on the customer account.")')">
|
||||||
@Html.AntiForgeryToken()
|
@Html.AntiForgeryToken()
|
||||||
<button type="submit" class="btn btn-outline-warning w-100">
|
<button type="submit" class="btn btn-outline-warning w-100">
|
||||||
<i class="bi bi-x-circle me-2"></i>Void Invoice
|
<i class="bi bi-x-circle me-2"></i>Void Invoice
|
||||||
@@ -1442,6 +1447,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';
|
||||||
|
|||||||
@@ -324,6 +324,7 @@
|
|||||||
@* -- Catalog Products -- *@
|
@* -- Catalog Products -- *@
|
||||||
@if (catalogItems.Any())
|
@if (catalogItems.Any())
|
||||||
{
|
{
|
||||||
|
<div class="d-none d-lg-block">
|
||||||
<h6 class="text-primary mb-3"><i class="bi bi-bag-check me-2"></i>Catalog Products</h6>
|
<h6 class="text-primary mb-3"><i class="bi bi-bag-check me-2"></i>Catalog Products</h6>
|
||||||
<div class="table-responsive mb-4">
|
<div class="table-responsive mb-4">
|
||||||
<table class="table table-hover table-sm">
|
<table class="table table-hover table-sm">
|
||||||
@@ -340,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 />
|
||||||
@@ -398,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>
|
||||||
@@ -412,11 +413,13 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@* -- Custom Work -- *@
|
@* -- Custom Work -- *@
|
||||||
@if (customItems.Any())
|
@if (customItems.Any())
|
||||||
{
|
{
|
||||||
|
<div class="d-none d-lg-block">
|
||||||
<h6 class="text-success mb-3"><i class="bi bi-calculator me-2"></i>Custom Work</h6>
|
<h6 class="text-success mb-3"><i class="bi bi-calculator me-2"></i>Custom Work</h6>
|
||||||
<div class="table-responsive mb-4">
|
<div class="table-responsive mb-4">
|
||||||
<table class="table table-hover table-sm">
|
<table class="table table-hover table-sm">
|
||||||
@@ -437,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))
|
||||||
{
|
{
|
||||||
@@ -455,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 />
|
||||||
@@ -525,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)
|
||||||
{
|
{
|
||||||
@@ -550,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>
|
||||||
@@ -563,11 +567,13 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@* -- Labor -- *@
|
@* -- Labor -- *@
|
||||||
@if (laborItems.Any())
|
@if (laborItems.Any())
|
||||||
{
|
{
|
||||||
|
<div class="d-none d-lg-block">
|
||||||
<h6 class="text-warning mb-3"><i class="bi bi-person-gear me-2"></i>Labor</h6>
|
<h6 class="text-warning mb-3"><i class="bi bi-person-gear me-2"></i>Labor</h6>
|
||||||
<div class="table-responsive mb-4">
|
<div class="table-responsive mb-4">
|
||||||
<table class="table table-hover table-sm">
|
<table class="table table-hover table-sm">
|
||||||
@@ -585,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)
|
||||||
{
|
{
|
||||||
@@ -601,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>
|
||||||
@@ -614,6 +620,7 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@* -- Mobile cards -- *@
|
@* -- Mobile cards -- *@
|
||||||
@@ -1682,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;
|
||||||
@@ -1711,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>
|
||||||
@@ -2409,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;
|
||||||
|
|||||||
@@ -8,10 +8,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@{
|
@{
|
||||||
var _wip = Model.Items.Count(j => j.StatusIsWIP);
|
var _allCount = (int)(ViewBag.AllJobCount ?? 0);
|
||||||
var _done = Model.Items.Count(j => j.StatusCode == "COMPLETED" || j.StatusCode == "READYFORPICKUP" || j.StatusCode == "DELIVERED");
|
var _wip = (int)(ViewBag.ActiveCount ?? 0);
|
||||||
var _overdue = Model.Items.Count(j => j.DueDate.HasValue && j.DueDate.Value < DateTime.Now && j.StatusCode != "COMPLETED" && j.StatusCode != "READYFORPICKUP" && j.StatusCode != "DELIVERED" && j.StatusCode != "CANCELLED");
|
var _done = (int)(ViewBag.CompletedCount ?? 0);
|
||||||
var _value = Model.Items.Sum(j => j.FinalPrice);
|
var _ready = (int)(ViewBag.ReadyCount ?? 0);
|
||||||
|
var _overdue = (int)(ViewBag.OverdueCount ?? 0);
|
||||||
|
var _value = Model.Items.Sum(j => j.FinalPrice);
|
||||||
}
|
}
|
||||||
<div class="pcl-metric-strip">
|
<div class="pcl-metric-strip">
|
||||||
<div class="pcl-metric-strip-cell">
|
<div class="pcl-metric-strip-cell">
|
||||||
@@ -39,23 +41,23 @@
|
|||||||
Showing <strong>@Model.TotalCount</strong> job(s) matching "<strong>@ViewBag.SearchTerm</strong>"
|
Showing <strong>@Model.TotalCount</strong> job(s) matching "<strong>@ViewBag.SearchTerm</strong>"
|
||||||
<small class="text-muted ms-2">(searches job number, description, customer, PO, instructions, status, priority)</small>
|
<small class="text-muted ms-2">(searches job number, description, customer, PO, instructions, status, priority)</small>
|
||||||
</div>
|
</div>
|
||||||
<a href="@Url.Action("Index")" class="btn btn-sm btn-outline-secondary">
|
<a href="@Url.Action("Index", new { statusGroup = "active" })" class="btn btn-sm btn-outline-secondary">
|
||||||
<i class="bi bi-x me-1"></i>Clear Filter
|
<i class="bi bi-x me-1"></i>Clear Filter
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (!string.IsNullOrEmpty(ViewBag.StatusGroup as string))
|
@if (!string.IsNullOrEmpty(ViewBag.StatusGroup as string) && ViewBag.StatusGroup != "active" && ViewBag.StatusGroup != "all")
|
||||||
{
|
{
|
||||||
var groupLabel = ViewBag.StatusGroup == "active" ? "Active Jobs (excluding completed & cancelled)"
|
var groupLabel = ViewBag.StatusGroup == "overdue" ? "Overdue Jobs (past due date)"
|
||||||
: ViewBag.StatusGroup == "overdue" ? "Overdue Jobs (past due date)"
|
: ViewBag.StatusGroup == "completed" ? "Completed Jobs (completed, ready for pickup & delivered)"
|
||||||
: ViewBag.StatusGroup;
|
: (string)ViewBag.StatusGroup;
|
||||||
<div class="alert alert-warning alert-permanent d-flex justify-content-between align-items-center">
|
<div class="alert alert-warning alert-permanent d-flex justify-content-between align-items-center">
|
||||||
<div>
|
<div>
|
||||||
<i class="bi bi-funnel-fill me-2"></i>
|
<i class="bi bi-funnel-fill me-2"></i>
|
||||||
Showing: <strong>@groupLabel</strong> — @Model.TotalCount result@(Model.TotalCount == 1 ? "" : "s")
|
Showing: <strong>@groupLabel</strong> — @Model.TotalCount result@(Model.TotalCount == 1 ? "" : "s")
|
||||||
</div>
|
</div>
|
||||||
<a href="@Url.Action("Index")" class="btn btn-sm btn-outline-secondary">
|
<a href="@Url.Action("Index", new { statusGroup = "active" })" class="btn btn-sm btn-outline-secondary">
|
||||||
<i class="bi bi-x me-1"></i>Show All
|
<i class="bi bi-x me-1"></i>Back to On Floor
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -63,7 +65,8 @@
|
|||||||
@{
|
@{
|
||||||
var _activeGroup = ViewBag.StatusGroup as string;
|
var _activeGroup = ViewBag.StatusGroup as string;
|
||||||
var _activeSearch = ViewBag.SearchTerm as string;
|
var _activeSearch = ViewBag.SearchTerm as string;
|
||||||
var _noFilter = string.IsNullOrEmpty(_activeGroup) && string.IsNullOrEmpty(_activeSearch) && string.IsNullOrEmpty(ViewBag.TagFilter as string);
|
// "all" is the explicit show-everything group (bare URL now redirects to "active")
|
||||||
|
var _noFilter = _activeGroup == "all" && string.IsNullOrEmpty(_activeSearch) && string.IsNullOrEmpty(ViewBag.TagFilter as string);
|
||||||
}
|
}
|
||||||
<!-- Jobs Table Card -->
|
<!-- Jobs Table Card -->
|
||||||
<div class="card border-0 shadow-sm">
|
<div class="card border-0 shadow-sm">
|
||||||
@@ -135,8 +138,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Row 2: quick-view pills -->
|
<!-- Row 2: quick-view pills -->
|
||||||
<div class="pcl-pill-group">
|
<div class="pcl-pill-group">
|
||||||
<a href="@Url.Action("Index")" class="pcl-pill @(_noFilter ? "active" : "")">
|
<a href="@Url.Action("Index", new { statusGroup = "all" })" class="pcl-pill @(_noFilter ? "active" : "")">
|
||||||
All <span class="pcl-pill-count">@Model.TotalCount</span>
|
All <span class="pcl-pill-count">@_allCount</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="@Url.Action("Index", new { statusGroup = "active" })" class="pcl-pill @(_activeGroup == "active" ? "active" : "")">
|
<a href="@Url.Action("Index", new { statusGroup = "active" })" class="pcl-pill @(_activeGroup == "active" ? "active" : "")">
|
||||||
On floor <span class="pcl-pill-count">@_wip</span>
|
On floor <span class="pcl-pill-count">@_wip</span>
|
||||||
@@ -145,7 +148,10 @@
|
|||||||
Overdue <span class="pcl-pill-count">@_overdue</span>
|
Overdue <span class="pcl-pill-count">@_overdue</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="@Url.Action("Index", new { searchTerm = "ReadyForPickup" })" class="pcl-pill @(_activeSearch == "ReadyForPickup" ? "active" : "")">
|
<a href="@Url.Action("Index", new { searchTerm = "ReadyForPickup" })" class="pcl-pill @(_activeSearch == "ReadyForPickup" ? "active" : "")">
|
||||||
Ready <span class="pcl-pill-count">@_done</span>
|
Ready <span class="pcl-pill-count">@_ready</span>
|
||||||
|
</a>
|
||||||
|
<a href="@Url.Action("Index", new { statusGroup = "completed" })" class="pcl-pill @(_activeGroup == "completed" ? "active" : "")">
|
||||||
|
Completed <span class="pcl-pill-count">@_done</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -75,11 +75,11 @@
|
|||||||
<div class="input-group mb-1">
|
<div class="input-group mb-1">
|
||||||
<span class="input-group-text">$</span>
|
<span class="input-group-text">$</span>
|
||||||
<input type="number" id="paymentAmount" class="form-control"
|
<input type="number" id="paymentAmount" class="form-control"
|
||||||
value="@Model.BalanceDue.ToString("F2")"
|
value="@Model.TotalWithSurcharge.ToString("F2")"
|
||||||
min="0.01" max="@Model.BalanceDue.ToString("F2")" step="0.01"
|
min="0.01" max="@Model.TotalWithSurcharge.ToString("F2")" step="0.01"
|
||||||
oninput="updateSurcharge()" />
|
oninput="updateSurcharge()" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-text">Max: @Model.BalanceDue.ToString("C")</div>
|
<div class="form-text">Max: @Model.TotalWithSurcharge.ToString("C")</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -116,18 +116,17 @@
|
|||||||
const STRIPE_PK = '@Model.StripePublishableKey';
|
const STRIPE_PK = '@Model.StripePublishableKey';
|
||||||
const ACCOUNT_ID = '@Model.StripeAccountId';
|
const ACCOUNT_ID = '@Model.StripeAccountId';
|
||||||
const TOKEN = '@Model.Token';
|
const TOKEN = '@Model.Token';
|
||||||
const MAX_AMOUNT = @Model.BalanceDue.ToString("F2");
|
const MAX_TOTAL = @Model.TotalWithSurcharge.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
|
||||||
const SURCHARGE_TYPE = '@Model.SurchargeType';
|
const SURCHARGE_TYPE = '@Model.SurchargeType';
|
||||||
const SURCHARGE_VALUE = @Model.SurchargeValue.ToString("F4");
|
const SURCHARGE_VALUE = @Model.SurchargeValue.ToString("F4", System.Globalization.CultureInfo.InvariantCulture);
|
||||||
const SUCCESS_URL = `/pay/${TOKEN}/success`;
|
const SUCCESS_URL = `/pay/${TOKEN}/success`;
|
||||||
|
|
||||||
const stripe = Stripe(STRIPE_PK, { stripeAccount: ACCOUNT_ID });
|
const stripe = Stripe(STRIPE_PK, { stripeAccount: ACCOUNT_ID });
|
||||||
let elements, paymentElement;
|
let elements, paymentElement;
|
||||||
|
|
||||||
async function initStripe() {
|
async function initStripe() {
|
||||||
const amount = parseFloat(document.getElementById('paymentAmount').value) || MAX_AMOUNT;
|
// Input value is the total the customer pays (including fee)
|
||||||
const surcharge = calcSurcharge(amount);
|
const total = parseFloat(document.getElementById('paymentAmount').value) || MAX_TOTAL;
|
||||||
const total = Math.round((amount + surcharge) * 100) / 100;
|
|
||||||
|
|
||||||
elements = stripe.elements({
|
elements = stripe.elements({
|
||||||
mode: 'payment',
|
mode: 'payment',
|
||||||
@@ -141,12 +140,15 @@
|
|||||||
paymentElement.mount('#payment-element');
|
paymentElement.mount('#payment-element');
|
||||||
}
|
}
|
||||||
|
|
||||||
function calcSurcharge(amount) {
|
// Back-calculates the base invoice amount from a total that includes the fee.
|
||||||
|
// Server receives the base and adds surcharge on top — both paths arrive at the same
|
||||||
|
// PaymentIntent amount so Stripe.js never sees an amount mismatch.
|
||||||
|
function calcBaseFromTotal(total) {
|
||||||
if (SURCHARGE_TYPE === 'Percent')
|
if (SURCHARGE_TYPE === 'Percent')
|
||||||
return Math.round(amount * (SURCHARGE_VALUE / 100) * 100) / 100;
|
return Math.round(total / (1 + SURCHARGE_VALUE / 100) * 100) / 100;
|
||||||
if (SURCHARGE_TYPE === 'Flat')
|
if (SURCHARGE_TYPE === 'Flat')
|
||||||
return SURCHARGE_VALUE;
|
return Math.max(0, Math.round((total - SURCHARGE_VALUE) * 100) / 100);
|
||||||
return 0;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCurrency(val) {
|
function formatCurrency(val) {
|
||||||
@@ -154,9 +156,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateSurcharge() {
|
function updateSurcharge() {
|
||||||
const amount = Math.min(parseFloat(document.getElementById('paymentAmount').value) || 0, MAX_AMOUNT);
|
const total = Math.min(parseFloat(document.getElementById('paymentAmount').value) || 0, MAX_TOTAL);
|
||||||
const surcharge = calcSurcharge(amount);
|
const base = calcBaseFromTotal(total);
|
||||||
const total = amount + surcharge;
|
const surcharge = Math.round((total - base) * 100) / 100;
|
||||||
|
|
||||||
const surchargeEl = document.getElementById('surchargeDisplay');
|
const surchargeEl = document.getElementById('surchargeDisplay');
|
||||||
const totalEl = document.getElementById('totalWithSurchargeDisplay');
|
const totalEl = document.getElementById('totalWithSurchargeDisplay');
|
||||||
@@ -164,7 +166,10 @@
|
|||||||
|
|
||||||
if (surchargeEl) surchargeEl.textContent = formatCurrency(surcharge);
|
if (surchargeEl) surchargeEl.textContent = formatCurrency(surcharge);
|
||||||
if (totalEl) totalEl.textContent = formatCurrency(total);
|
if (totalEl) totalEl.textContent = formatCurrency(total);
|
||||||
if (btnAmtEl) btnAmtEl.textContent = surcharge > 0 ? formatCurrency(total) : formatCurrency(amount);
|
if (btnAmtEl) btnAmtEl.textContent = formatCurrency(total);
|
||||||
|
|
||||||
|
// Keep Elements in sync so confirmPayment amount matches the PaymentIntent
|
||||||
|
if (elements) elements.update({ amount: Math.round(total * 100) });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitPayment() {
|
async function submitPayment() {
|
||||||
@@ -172,13 +177,21 @@
|
|||||||
const msgEl = document.getElementById('payment-message');
|
const msgEl = document.getElementById('payment-message');
|
||||||
const amountInput = document.getElementById('paymentAmount');
|
const amountInput = document.getElementById('paymentAmount');
|
||||||
|
|
||||||
const amount = parseFloat(amountInput.value);
|
const total = parseFloat(amountInput.value);
|
||||||
if (!amount || amount <= 0 || amount > MAX_AMOUNT) {
|
if (!total || total <= 0 || total > MAX_TOTAL) {
|
||||||
msgEl.textContent = 'Please enter a valid payment amount.';
|
msgEl.textContent = 'Please enter a valid payment amount.';
|
||||||
msgEl.style.display = '';
|
msgEl.style.display = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Back-calculate base; server adds surcharge on top as usual
|
||||||
|
const base = calcBaseFromTotal(total);
|
||||||
|
if (base <= 0) {
|
||||||
|
msgEl.textContent = 'Payment amount must exceed the convenience fee.';
|
||||||
|
msgEl.style.display = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
document.getElementById('submitText').style.display = 'none';
|
document.getElementById('submitText').style.display = 'none';
|
||||||
document.getElementById('submitSpinner').style.display = '';
|
document.getElementById('submitSpinner').style.display = '';
|
||||||
@@ -195,13 +208,13 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create PaymentIntent on server
|
// Create PaymentIntent on server — send base amount (server adds surcharge)
|
||||||
let clientSecret;
|
let clientSecret;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/pay/${TOKEN}/intent`, {
|
const resp = await fetch(`/pay/${TOKEN}/intent`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ amount })
|
body: JSON.stringify({ amount: base })
|
||||||
});
|
});
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
|
|||||||
@@ -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