Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7fa385aeb8 | |||
| 8452ea3fcd | |||
| 9b34ff564e | |||
| 24f3df1bbc | |||
| 551116d7e5 | |||
| 8768e9813b | |||
| 4a7087cc0c | |||
| 59b152c89f | |||
| 441898b52f | |||
| 3e30397302 |
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -264,6 +264,7 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
TransferEfficiency = coatDto.TransferEfficiency,
|
TransferEfficiency = coatDto.TransferEfficiency,
|
||||||
PowderCostPerLb = coatDto.PowderCostPerLb,
|
PowderCostPerLb = coatDto.PowderCostPerLb,
|
||||||
PowderToOrder = coatDto.PowderToOrder,
|
PowderToOrder = coatDto.PowderToOrder,
|
||||||
|
NoExtraLayerCharge = coatDto.NoExtraLayerCharge,
|
||||||
Notes = coatDto.Notes,
|
Notes = coatDto.Notes,
|
||||||
CompanyId = companyId,
|
CompanyId = companyId,
|
||||||
CreatedAt = createdAtUtc
|
CreatedAt = createdAtUtc
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -3016,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).
|
||||||
@@ -3031,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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -477,6 +510,7 @@ public class JobsController : Controller
|
|||||||
transferEfficiency = c.TransferEfficiency,
|
transferEfficiency = c.TransferEfficiency,
|
||||||
powderCostPerLb = c.PowderCostPerLb,
|
powderCostPerLb = c.PowderCostPerLb,
|
||||||
powderToOrder = c.PowderToOrder,
|
powderToOrder = c.PowderToOrder,
|
||||||
|
noExtraLayerCharge = c.NoExtraLayerCharge,
|
||||||
notes = c.Notes
|
notes = c.Notes
|
||||||
}),
|
}),
|
||||||
prepServices = ji.PrepServices.Select(ps => new {
|
prepServices = ji.PrepServices.Select(ps => new {
|
||||||
@@ -2946,6 +2980,7 @@ public class JobsController : Controller
|
|||||||
TransferEfficiency = c.TransferEfficiency,
|
TransferEfficiency = c.TransferEfficiency,
|
||||||
PowderCostPerLb = c.PowderCostPerLb,
|
PowderCostPerLb = c.PowderCostPerLb,
|
||||||
PowderToOrder = c.PowderToOrder,
|
PowderToOrder = c.PowderToOrder,
|
||||||
|
NoExtraLayerCharge = c.NoExtraLayerCharge,
|
||||||
Notes = c.Notes
|
Notes = c.Notes
|
||||||
}).ToList(),
|
}).ToList(),
|
||||||
PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto
|
PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto
|
||||||
@@ -3126,7 +3161,8 @@ public class JobsController : Controller
|
|||||||
InventoryItemId = c.InventoryItemId,
|
InventoryItemId = c.InventoryItemId,
|
||||||
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
||||||
TransferEfficiency = c.TransferEfficiency,
|
TransferEfficiency = c.TransferEfficiency,
|
||||||
PowderCostPerLb = c.PowderCostPerLb
|
PowderCostPerLb = c.PowderCostPerLb,
|
||||||
|
NoExtraLayerCharge = c.NoExtraLayerCharge
|
||||||
}).ToList()
|
}).ToList()
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
@@ -4180,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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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