Compare commits

..

5 Commits

Author SHA1 Message Date
spouliot 25140554ad Merge hotfixes: Stripe receipt_email, surcharge fix, void deposit/credit, cache headers
- Remove receipt_email from Stripe PaymentIntent (any email accepted at checkout)
- Fix surcharge payment: input/validation based on total-with-fee, not base amount
- Add InvariantCulture to payment JS literals
- Fix voided invoice leaving deposits locked (re-releases for next invoice)
- Convert non-deposit payments to CRED- credits on void (preserves money trail)
- Cache-Control: no-store on authenticated pages (prevents browser cache corruption)
- Fix Edit Payment onclick encoding for apostrophes in reference/notes

Inline item editing (7fa385a) held in dev pending further testing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:19:10 -04:00
spouliot 46cadea367 Add Cache-Control: no-store for authenticated pages; fix payment onclick encoding
Prevents browsers from caching authenticated pages, which resolves stale/corrupt
cache bugs (e.g. Firefox refusing to navigate to a specific invoice). Also fixes
the Edit Payment button onclick to use Json.Serialize for Reference/Notes so
apostrophes and other special characters don't break the JavaScript string literal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:18:04 -04:00
spouliot cfe937c0c3 Convert non-deposit payments to customer credits on invoice void
When voiding an invoice that has non-deposit payments (e.g. CC charges),
those payments are now converted to CRED- Deposit records so the money
trail is preserved and the credit auto-applies to the replacement invoice.
Deposits that were applied to the voided invoice are also re-released so
they can auto-apply again. Void confirmation dialog and success message
both reflect the credit amount when applicable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:18:03 -04:00
spouliot 3ad6b0d08f Fix voided invoice leaving deposits locked as applied
When an invoice was voided, deposits auto-applied at invoice creation
kept their AppliedToInvoiceId pointing at the voided invoice. The
replacement invoice lookup (AppliedToInvoiceId == null) skipped them,
so the deposit was never re-applied and the customer was charged in full.

Void now clears AppliedToInvoiceId/AppliedDate on all deposits tied to
the invoice so they're available for the next invoice, and credits the
CustomerDeposits 2300 liability account to restore the balance that was
debited when the deposits were originally applied.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:18:01 -04:00
spouliot fdac0240d1 Fix Stripe receipt_email + online payment surcharge and hardening
Remove receipt_email from PaymentIntent creation so customers can use
any email at Stripe checkout without a stored-email mismatch blocking
payment. Remove now-dead CustomerEmail from PaymentPageViewModel.

Fix surcharge payment input: amount field now represents the total the
customer pays (including fee); JS back-calculates base before sending
to server. Add InvariantCulture to numeric Razor→JS literals to prevent
comma-decimal cultures from truncating surcharge values.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:17:57 -04:00
13 changed files with 64 additions and 554 deletions
@@ -355,15 +355,6 @@ 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)
@@ -413,16 +404,6 @@ 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,
@@ -431,7 +412,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 = derivedColor, ColorName = item.ColorName,
Notes = item.Notes, Notes = item.Notes,
DisplayOrder = order++, DisplayOrder = order++,
RevenueAccountId = revenueAccountId RevenueAccountId = revenueAccountId
@@ -3108,50 +3089,6 @@ 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).
@@ -3167,11 +3104,3 @@ 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,11 +110,6 @@ 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
{ {
@@ -146,13 +141,6 @@ 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))
{ {
@@ -207,27 +195,6 @@ 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;
@@ -4216,69 +4183,9 @@ 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; }
@@ -3824,49 +3824,6 @@ 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
@@ -3877,11 +3834,3 @@ 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,15 +265,12 @@ 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. Optionally set Oven & Batch Settings select a named oven, number of batches, and cycle time. These affect the oven cost in pricing. 5. Save
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.
@@ -305,9 +302,7 @@ 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.
**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. 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.
**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 &mdash; invoices go to contact email</span> <span class="text-muted">Not set — invoices go to contact email</span>
} }
</p> </p>
</div> </div>
+4 -48
View File
@@ -25,13 +25,9 @@
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 &rsaquo; Jobs</strong> in the left sidebar. The list You can find Jobs under <strong>Operations &rsaquo; Jobs</strong> in the left sidebar. The list is
opens on the <strong>On Floor</strong> filter by default, showing only active in-progress work searchable and sortable by job number, status, priority, scheduled date, due date, and price. Jobs
so completed jobs don&rsquo;t clutter the screen. Use the filter pills at the top to switch can be created manually or converted automatically from an approved quote — no need to re-enter
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 &mdash; 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">
@@ -58,11 +54,6 @@
<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 &amp; 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&rsquo;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>
@@ -362,7 +353,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&rsquo;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">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">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>
@@ -379,40 +370,6 @@
</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> &mdash; defaults to today.</li>
<li><strong>Actual hours spent</strong> on the job.</li>
<li><strong>Final price</strong> &mdash; pre-filled from the job; adjust if needed.</li>
<li>
<strong>Powder usage</strong> &mdash; 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 &mdash; 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 &mdash; 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
@@ -742,7 +699,6 @@
<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>
@@ -233,21 +233,21 @@
<tbody> <tbody>
@foreach (var item in Model.InvoiceItems) @foreach (var item in Model.InvoiceItems)
{ {
<tr data-item-id="@item.Id"> <tr>
<td> <td>
<span class="fw-semibold" data-inline-field="description" data-raw-value="@item.Description">@item.Description</span> <div class="fw-semibold">@item.Description</div>
@if (!string.IsNullOrWhiteSpace(item.ColorName)) @if (!string.IsNullOrWhiteSpace(item.ColorName))
{ {
<small class="text-muted d-block">@item.ColorName</small> <small class="text-muted">@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"><span data-inline-field="quantity" data-raw-value="@item.Quantity">@item.Quantity.ToString("G")</span></td> <td class="text-center">@item.Quantity.ToString("G")</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">@item.UnitPrice.ToString("C")</td>
<td class="text-end fw-semibold" data-line-total>@item.TotalPrice.ToString("C")</td> <td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td>
</tr> </tr>
} }
</tbody> </tbody>
@@ -260,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 id="inv-subtotal">@Model.SubTotal.ToString("C")</span> <span>@Model.SubTotal.ToString("C")</span>
</div> </div>
@if (Model.DiscountAmount > 0) @if (Model.DiscountAmount > 0)
{ {
@@ -279,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 id="inv-tax">@Model.TaxAmount.ToString("C")</span> <span>@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 id="inv-total">@Model.Total.ToString("C")</span> <span>@Model.Total.ToString("C")</span>
</div> </div>
@if (Model.AmountPaid > 0) @if (Model.AmountPaid > 0)
{ {
@@ -294,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 id="inv-balance" class="@(Model.BalanceDue > 0 ? "text-danger" : "text-success")">@Model.BalanceDue.ToString("C")</span> <span class="@(Model.BalanceDue > 0 ? "text-danger" : "text-success")">@Model.BalanceDue.ToString("C")</span>
</div> </div>
} }
</div> </div>
@@ -1447,19 +1447,6 @@
} }
@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';
+17 -34
View File
@@ -324,7 +324,6 @@
@* -- 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">
@@ -341,9 +340,9 @@
@foreach (var item in catalogItems) @foreach (var item in catalogItems)
{ {
var catIdx = allItems.IndexOf(item); var catIdx = allItems.IndexOf(item);
<tr data-item-id="@item.Id"> <tr>
<td> <td>
<span data-inline-field="description" data-raw-value="@item.Description"><strong>@item.Description</strong></span> <strong>@item.Description</strong>
@if (item.Coats != null && item.Coats.Any()) @if (item.Coats != null && item.Coats.Any())
{ {
<br /> <br />
@@ -399,9 +398,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"><span data-inline-field="quantity" data-raw-value="@item.Quantity">@item.Quantity</span></td> <td class="text-center">@item.Quantity</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">@item.UnitPrice.ToString("C")</td>
<td class="text-end fw-semibold" data-line-total>@item.TotalPrice.ToString("C")</td> <td class="text-end fw-semibold">@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>
@@ -413,13 +412,11 @@
</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">
@@ -440,7 +437,6 @@
{ {
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))
{ {
@@ -459,9 +455,9 @@
{ {
totalPowderNeeded = (item.SurfaceAreaSqFt * item.Quantity) / (30m * 0.65m); totalPowderNeeded = (item.SurfaceAreaSqFt * item.Quantity) / (30m * 0.65m);
} }
<tr data-item-id="@item.Id"> <tr>
<td> <td>
<span data-inline-field="description" data-raw-value="@item.Description"><strong>@item.Description</strong></span> <strong>@item.Description</strong>
@if (item.RequiresSandblasting || item.RequiresMasking) @if (item.RequiresSandblasting || item.RequiresMasking)
{ {
<br /> <br />
@@ -529,7 +525,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"><span data-inline-field="quantity" data-raw-value="@item.Quantity">@item.Quantity</span></td> <td class="text-center">@item.Quantity</td>
<td class="text-center"> <td class="text-center">
@if (item.SurfaceAreaSqFt > 0) @if (item.SurfaceAreaSqFt > 0)
{ {
@@ -554,8 +550,8 @@
} }
else { <span class="text-muted">&mdash;</span> } else { <span class="text-muted">&mdash;</span> }
</td> </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">@item.UnitPrice.ToString("C")</td>
<td class="text-end fw-semibold" data-line-total>@item.TotalPrice.ToString("C")</td> <td class="text-end fw-semibold">@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>
@@ -567,13 +563,11 @@
</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">
@@ -591,15 +585,15 @@
@foreach (var item in laborItems) @foreach (var item in laborItems)
{ {
var labIdx = allItems.IndexOf(item); var labIdx = allItems.IndexOf(item);
<tr data-item-id="@item.Id"> <tr>
<td> <td>
<span data-inline-field="description" data-raw-value="@item.Description"><strong>@item.Description</strong></span> <strong>@item.Description</strong>
@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"><span data-inline-field="quantity" data-raw-value="@item.Quantity">@item.Quantity</span></td> <td class="text-center">@item.Quantity</td>
<td class="text-center"> <td class="text-center">
@if (item.EstimatedMinutes > 0) @if (item.EstimatedMinutes > 0)
{ {
@@ -607,8 +601,8 @@
} }
else { <span class="text-muted">&mdash;</span> } else { <span class="text-muted">&mdash;</span> }
</td> </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">@item.UnitPrice.ToString("C")</td>
<td class="text-end fw-semibold" data-line-total>@item.TotalPrice.ToString("C")</td> <td class="text-end fw-semibold">@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>
@@ -620,7 +614,6 @@
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
} }
@* -- Mobile cards -- *@ @* -- Mobile cards -- *@
@@ -1689,7 +1682,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 class="job-final-price-display">@jobPb.Total.ToString("C")</span> <span>@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;
@@ -1718,7 +1711,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 job-final-price-display">@Model.FinalPrice.ToString("C")</h3> <h3 class="mb-0 text-primary">@Model.FinalPrice.ToString("C")</h3>
</div> </div>
} }
</div> </div>
@@ -2416,16 +2409,6 @@
<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;
+15 -21
View File
@@ -8,12 +8,10 @@
} }
@{ @{
var _allCount = (int)(ViewBag.AllJobCount ?? 0); var _wip = Model.Items.Count(j => j.StatusIsWIP);
var _wip = (int)(ViewBag.ActiveCount ?? 0); var _done = Model.Items.Count(j => j.StatusCode == "COMPLETED" || j.StatusCode == "READYFORPICKUP" || j.StatusCode == "DELIVERED");
var _done = (int)(ViewBag.CompletedCount ?? 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 _ready = (int)(ViewBag.ReadyCount ?? 0); var _value = Model.Items.Sum(j => j.FinalPrice);
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">
@@ -41,23 +39,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", new { statusGroup = "active" })" class="btn btn-sm btn-outline-secondary"> <a href="@Url.Action("Index")" 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) && ViewBag.StatusGroup != "active" && ViewBag.StatusGroup != "all") @if (!string.IsNullOrEmpty(ViewBag.StatusGroup as string))
{ {
var groupLabel = ViewBag.StatusGroup == "overdue" ? "Overdue Jobs (past due date)" var groupLabel = ViewBag.StatusGroup == "active" ? "Active Jobs (excluding completed & cancelled)"
: ViewBag.StatusGroup == "completed" ? "Completed Jobs (completed, ready for pickup & delivered)" : ViewBag.StatusGroup == "overdue" ? "Overdue Jobs (past due date)"
: (string)ViewBag.StatusGroup; : 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> &mdash; @Model.TotalCount result@(Model.TotalCount == 1 ? "" : "s") Showing: <strong>@groupLabel</strong> &mdash; @Model.TotalCount result@(Model.TotalCount == 1 ? "" : "s")
</div> </div>
<a href="@Url.Action("Index", new { statusGroup = "active" })" class="btn btn-sm btn-outline-secondary"> <a href="@Url.Action("Index")" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-x me-1"></i>Back to On Floor <i class="bi bi-x me-1"></i>Show All
</a> </a>
</div> </div>
} }
@@ -65,8 +63,7 @@
@{ @{
var _activeGroup = ViewBag.StatusGroup as string; var _activeGroup = ViewBag.StatusGroup as string;
var _activeSearch = ViewBag.SearchTerm as string; var _activeSearch = ViewBag.SearchTerm as string;
// "all" is the explicit show-everything group (bare URL now redirects to "active") var _noFilter = string.IsNullOrEmpty(_activeGroup) && string.IsNullOrEmpty(_activeSearch) && string.IsNullOrEmpty(ViewBag.TagFilter as string);
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">
@@ -138,8 +135,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", new { statusGroup = "all" })" class="pcl-pill @(_noFilter ? "active" : "")"> <a href="@Url.Action("Index")" class="pcl-pill @(_noFilter ? "active" : "")">
All <span class="pcl-pill-count">@_allCount</span> All <span class="pcl-pill-count">@Model.TotalCount</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>
@@ -148,10 +145,7 @@
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">@_ready</span> Ready <span class="pcl-pill-count">@_done</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 data-item-id="@item.Id"> <tr>
<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;
} }
<span data-inline-field="description" data-raw-value="@displayDescription"><strong>@displayDescription</strong></span> <strong>@displayDescription</strong>
@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><span data-inline-field="quantity" data-raw-value="@item.Quantity">@item.Quantity</span></td> <td>@item.Quantity</td>
<td><span data-inline-field="unitPrice" data-raw-value="@item.UnitPrice">@item.UnitPrice.ToString("C")</span></td> <td>@item.UnitPrice.ToString("C")</td>
<td data-line-total><strong>@item.TotalPrice.ToString("C")</strong></td> <td><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 data-item-id="@item.Id"> <tr>
<td> <td>
<span data-inline-field="description" data-raw-value="@item.Description"><strong>@item.Description</strong></span> <strong>@item.Description</strong>
@* 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><span data-inline-field="quantity" data-raw-value="@item.Quantity">@item.Quantity</span></td> <td>@item.Quantity</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><span data-inline-field="unitPrice" data-raw-value="@item.UnitPrice">@item.UnitPrice.ToString("C")</span></td> <td>@item.UnitPrice.ToString("C")</td>
<td data-line-total><strong>@item.TotalPrice.ToString("C")</strong></td> <td><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 id="quote-subtotal">@Model.PricingBreakdown.SubtotalBeforeDiscount.ToString("C")</strong> <strong>@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 id="quote-tax">@Model.PricingBreakdown.TaxAmount.ToString("C")</strong> <strong>@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 id="quote-total">@Model.Total.ToString("C")</strong></h5> <h5 class="text-primary"><strong>@Model.Total.ToString("C")</strong></h5>
</div> </div>
@if (Model.PricingBreakdown.DiscountAmount > 0) @if (Model.PricingBreakdown.DiscountAmount > 0)
@@ -2262,18 +2262,6 @@
@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,14 +1152,3 @@ 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;
}
@@ -1,167 +0,0 @@
/// <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 &mdash; 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 &mdash; 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);
});
})();