From 551116d7e578ef6b5a4b6583813e016c7e000dcf Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Tue, 19 May 2026 19:34:54 -0400 Subject: [PATCH 01/22] Mobile layout fix for Job Details items; coat color on invoice line items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Job Details: hide desktop item tables on mobile (d-none d-lg-block) so only the existing mobile-card layout shows on small screens — prevents 20-line rows on a narrow phone display - Invoices Create (ForJob path): load job item coats and derive ColorName from coat colors when the item itself has no explicit color set; multiple coats join as 'Color1 / Color2' — lets customers distinguish repeated items (e.g. multiple caliper sets) on the invoice Co-Authored-By: Claude Sonnet 4.6 --- .../Controllers/InvoicesController.cs | 21 ++++++++++++++++++- .../Views/Jobs/Details.cshtml | 6 ++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/PowderCoating.Web/Controllers/InvoicesController.cs b/src/PowderCoating.Web/Controllers/InvoicesController.cs index ddb3537..8a6d39e 100644 --- a/src/PowderCoating.Web/Controllers/InvoicesController.cs +++ b/src/PowderCoating.Web/Controllers/InvoicesController.cs @@ -355,6 +355,15 @@ public class InvoicesController : Controller var job = await _unitOfWork.Jobs.GetByIdAsync(jobId.Value, false, j => j.Customer, j => j.JobItems); 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(); + 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) var existing = await _unitOfWork.Invoices.GetForJobAsync(jobId.Value); if (existing != null && existing.Status != InvoiceStatus.Voided) @@ -404,6 +413,16 @@ public class InvoicesController : Controller revenueAccountId = ci.RevenueAccountId; 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 { SourceJobItemId = item.Id, @@ -412,7 +431,7 @@ public class InvoicesController : Controller Quantity = item.Quantity > 0 ? item.Quantity : 1, UnitPrice = item.UnitPrice, TotalPrice = item.TotalPrice, - ColorName = item.ColorName, + ColorName = derivedColor, Notes = item.Notes, DisplayOrder = order++, RevenueAccountId = revenueAccountId diff --git a/src/PowderCoating.Web/Views/Jobs/Details.cshtml b/src/PowderCoating.Web/Views/Jobs/Details.cshtml index 2ca0385..ba820ec 100644 --- a/src/PowderCoating.Web/Views/Jobs/Details.cshtml +++ b/src/PowderCoating.Web/Views/Jobs/Details.cshtml @@ -324,6 +324,7 @@ @* -- Catalog Products -- *@ @if (catalogItems.Any()) { +
Catalog Products
@@ -412,11 +413,13 @@
+
} @* -- Custom Work -- *@ @if (customItems.Any()) { +
Custom Work
@@ -563,11 +566,13 @@
+
} @* -- Labor -- *@ @if (laborItems.Any()) { +
Labor
@@ -614,6 +619,7 @@
+
} @* -- Mobile cards -- *@ From 24f3df1bbcc220bb1299d3840666c0f61a5844b3 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Tue, 19 May 2026 20:11:33 -0400 Subject: [PATCH 02/22] Jobs list defaults to On Floor; add Completed filter pill; fix encoding bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /Jobs now redirects to ?statusGroup=active so completed jobs don't clutter the default view - Add Completed pill (filters Completed + ReadyForPickup + Delivered) - Pill badge counts are now global DB counts, not page-local item counts - Ready pill badge now shows ReadyForPickup-only count - All pill links to ?statusGroup=all to bypass the redirect - Fix double-encoded & in Completed filter alert label - Fix corrupted em dash (â€") in Customers/Details billing email fallback text Co-Authored-By: Claude Sonnet 4.6 --- .../Controllers/JobsController.cs | 33 +++++++++++++++++ .../Views/Customers/Details.cshtml | 2 +- src/PowderCoating.Web/Views/Jobs/Index.cshtml | 36 +++++++++++-------- 3 files changed, 55 insertions(+), 16 deletions(-) diff --git a/src/PowderCoating.Web/Controllers/JobsController.cs b/src/PowderCoating.Web/Controllers/JobsController.cs index 393ae28..a09a1e0 100644 --- a/src/PowderCoating.Web/Controllers/JobsController.cs +++ b/src/PowderCoating.Web/Controllers/JobsController.cs @@ -110,6 +110,11 @@ public class JobsController : Controller { 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 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.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)) { @@ -195,6 +207,27 @@ public class JobsController : Controller gridRequest, jobDtos, 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 ViewBag.SearchTerm = searchTerm; ViewBag.StatusGroup = statusGroup; diff --git a/src/PowderCoating.Web/Views/Customers/Details.cshtml b/src/PowderCoating.Web/Views/Customers/Details.cshtml index 0c641e4..8ee1522 100644 --- a/src/PowderCoating.Web/Views/Customers/Details.cshtml +++ b/src/PowderCoating.Web/Views/Customers/Details.cshtml @@ -123,7 +123,7 @@ } else { - Not set — invoices go to contact email + Not set — invoices go to contact email }

diff --git a/src/PowderCoating.Web/Views/Jobs/Index.cshtml b/src/PowderCoating.Web/Views/Jobs/Index.cshtml index 6d3e6d5..6be0d17 100644 --- a/src/PowderCoating.Web/Views/Jobs/Index.cshtml +++ b/src/PowderCoating.Web/Views/Jobs/Index.cshtml @@ -8,10 +8,12 @@ } @{ - var _wip = Model.Items.Count(j => j.StatusIsWIP); - var _done = Model.Items.Count(j => j.StatusCode == "COMPLETED" || j.StatusCode == "READYFORPICKUP" || j.StatusCode == "DELIVERED"); - 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 _value = Model.Items.Sum(j => j.FinalPrice); + var _allCount = (int)(ViewBag.AllJobCount ?? 0); + var _wip = (int)(ViewBag.ActiveCount ?? 0); + var _done = (int)(ViewBag.CompletedCount ?? 0); + var _ready = (int)(ViewBag.ReadyCount ?? 0); + var _overdue = (int)(ViewBag.OverdueCount ?? 0); + var _value = Model.Items.Sum(j => j.FinalPrice); }
@@ -39,23 +41,23 @@ Showing @Model.TotalCount job(s) matching "@ViewBag.SearchTerm" (searches job number, description, customer, PO, instructions, status, priority)
- + Clear Filter
} -@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)" - : ViewBag.StatusGroup == "overdue" ? "Overdue Jobs (past due date)" - : ViewBag.StatusGroup; + var groupLabel = ViewBag.StatusGroup == "overdue" ? "Overdue Jobs (past due date)" + : ViewBag.StatusGroup == "completed" ? "Completed Jobs (completed, ready for pickup & delivered)" + : (string)ViewBag.StatusGroup;
Showing: @groupLabel — @Model.TotalCount result@(Model.TotalCount == 1 ? "" : "s")
- - Show All + + Back to On Floor
} @@ -63,7 +65,8 @@ @{ var _activeGroup = ViewBag.StatusGroup 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); }
@@ -135,8 +138,8 @@
From 9b34ff564e6368a212ef1f1cdabc56004d4be55c Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Tue, 19 May 2026 20:15:58 -0400 Subject: [PATCH 03/22] Update AI assistant and help docs for recent changes HelpKnowledgeBase: - Jobs list: document On Floor default view and 5 filter pills (All/On Floor/Overdue/Ready/Completed) with global counts - Creating a job: add Oven & Batch Settings step - Completing a job: new entry explaining Complete Job modal, per-color powder grouping, QR scan credit - Invoice from job: note that coat colors appear in line item descriptions Help/Jobs.cshtml: - Overview: mention On Floor default and filter pills - Creating a job: add oven/batch settings step in the numbered list - New "Completing a Job" section: modal fields, powder grouping by color, QR credit, SMS behavior - Invoice from job step: mention coat color in line item descriptions - Add "Completing a Job" to page nav Co-Authored-By: Claude Sonnet 4.6 --- .../Helpers/HelpKnowledgeBase.cs | 9 +++- src/PowderCoating.Web/Views/Help/Jobs.cshtml | 52 +++++++++++++++++-- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs b/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs index 409baec..79d09c2 100644 --- a/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs +++ b/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs @@ -265,12 +265,15 @@ public static class HelpKnowledgeBase **Job Priorities (color-coded):** - 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:** 1. Go to [Jobs](/Jobs) → "New Job" 2. Select customer 3. Add line items (same wizard as quotes: Calculated, Custom Work, or AI Photo) 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. @@ -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. - **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. diff --git a/src/PowderCoating.Web/Views/Help/Jobs.cshtml b/src/PowderCoating.Web/Views/Help/Jobs.cshtml index 3dbe603..fb66bd8 100644 --- a/src/PowderCoating.Web/Views/Help/Jobs.cshtml +++ b/src/PowderCoating.Web/Views/Help/Jobs.cshtml @@ -25,9 +25,13 @@ a priority level, a due date, and one or more line items describing the work to be performed.

- You can find Jobs under Operations › Jobs in the left sidebar. The list is - searchable and sortable 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 + You can find Jobs under Operations › Jobs in the left sidebar. The list + opens on the On Floor filter by default, showing only active in-progress work + so completed jobs don’t clutter the screen. Use the filter pills at the top to switch + views: All, On Floor, Overdue, + Ready (awaiting pickup), and Completed. 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.

+
+

+ Completing a Job +

+

+ When work is done and the parts have passed quality check, click the Complete Job + button on the Job Details page. A modal opens where you confirm: +

+
    +
  • Completion date — defaults to today.
  • +
  • Actual hours spent on the job.
  • +
  • Final price — pre-filled from the job; adjust if needed.
  • +
  • + Powder usage — if the job used powder from inventory, you are asked + to enter actual lbs used. The modal groups all coats by unique powder color 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. +
  • +
+

+ Once confirmed, the job advances to Completed status, + inventory is updated, and you are prompted to create an invoice if one does not already exist. +

+ +
+

Photos and Notes @@ -699,6 +742,7 @@ Job Items Converting from a Quote Creating an Invoice + Completing a Job Photos and Notes Time Entries and Rework Job Templates From 7fa385aeb80a3942c32674a019be9a46c47b9e32 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Wed, 20 May 2026 11:49:04 -0400 Subject: [PATCH 04/22] Inline item editing on details pages; fix Stripe receipt_email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow description, quantity, and price to be edited inline on Quote, Job, and Invoice details pages without re-opening the wizard. Coating and prep service rows remain read-only by design. Invoice editing is gated to Draft/Sent/Overdue statuses; totals update live in the DOM. Remove receipt_email from Stripe PaymentIntent creation so customers can use any email they choose at checkout — Stripe validates format and sends the receipt to whatever the customer enters in the Payment Element, eliminating the risk of a stored email mismatch blocking a payment from processing. Co-Authored-By: Claude Sonnet 4.6 --- .../Interfaces/IStripeConnectService.cs | 2 - .../Services/StripeConnectService.cs | 4 - .../Controllers/InvoicesController.cs | 52 ++++++ .../Controllers/JobsController.cs | 60 +++++++ .../Controllers/PaymentController.cs | 5 - .../Controllers/QuotesController.cs | 51 ++++++ .../Views/Invoices/Details.cshtml | 33 ++-- .../Views/Jobs/Details.cshtml | 45 +++-- .../Views/Quotes/Details.cshtml | 38 ++-- src/PowderCoating.Web/dotnet-tools.json | 2 +- src/PowderCoating.Web/wwwroot/css/site.css | 11 ++ .../wwwroot/js/inline-item-edit.js | 167 ++++++++++++++++++ 12 files changed, 418 insertions(+), 52 deletions(-) create mode 100644 src/PowderCoating.Web/wwwroot/js/inline-item-edit.js diff --git a/src/PowderCoating.Application/Interfaces/IStripeConnectService.cs b/src/PowderCoating.Application/Interfaces/IStripeConnectService.cs index 9b7d162..432f48c 100644 --- a/src/PowderCoating.Application/Interfaces/IStripeConnectService.cs +++ b/src/PowderCoating.Application/Interfaces/IStripeConnectService.cs @@ -20,7 +20,6 @@ public interface IStripeConnectService decimal invoiceTotal, decimal surchargeAmount, string currency, - string customerEmail, string invoiceNumber, int invoiceId); @@ -33,7 +32,6 @@ public interface IStripeConnectService decimal depositAmount, decimal surchargeAmount, string currency, - string customerEmail, string quoteNumber, int quoteId); } diff --git a/src/PowderCoating.Infrastructure/Services/StripeConnectService.cs b/src/PowderCoating.Infrastructure/Services/StripeConnectService.cs index 4034057..caf1078 100644 --- a/src/PowderCoating.Infrastructure/Services/StripeConnectService.cs +++ b/src/PowderCoating.Infrastructure/Services/StripeConnectService.cs @@ -162,7 +162,6 @@ public class StripeConnectService : IStripeConnectService decimal invoiceTotal, decimal surchargeAmount, string currency, - string customerEmail, string invoiceNumber, int invoiceId) { @@ -175,7 +174,6 @@ public class StripeConnectService : IStripeConnectService { Amount = amountInCents, Currency = currency.ToLower(), - ReceiptEmail = customerEmail, Description = $"Invoice {invoiceNumber}", Metadata = new Dictionary { @@ -215,7 +213,6 @@ public class StripeConnectService : IStripeConnectService decimal depositAmount, decimal surchargeAmount, string currency, - string customerEmail, string quoteNumber, int quoteId) { @@ -228,7 +225,6 @@ public class StripeConnectService : IStripeConnectService { Amount = amountInCents, Currency = currency.ToLower(), - ReceiptEmail = customerEmail, Description = $"Deposit for quote {quoteNumber}", Metadata = new Dictionary { diff --git a/src/PowderCoating.Web/Controllers/InvoicesController.cs b/src/PowderCoating.Web/Controllers/InvoicesController.cs index 8a6d39e..d6ad3ab 100644 --- a/src/PowderCoating.Web/Controllers/InvoicesController.cs +++ b/src/PowderCoating.Web/Controllers/InvoicesController.cs @@ -3035,6 +3035,50 @@ public class InvoicesController : Controller return false; } + /// + /// 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. + /// + [HttpPost] + [ValidateAntiForgeryToken] + public async Task 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 + }); + } + /// /// Returns logo bytes and content type for PDF generation. /// Prefers blob-stored logos (LogoFilePath) over the legacy DB column (LogoData). @@ -3050,3 +3094,11 @@ public class InvoicesController : Controller 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; } +} diff --git a/src/PowderCoating.Web/Controllers/JobsController.cs b/src/PowderCoating.Web/Controllers/JobsController.cs index a09a1e0..2582725 100644 --- a/src/PowderCoating.Web/Controllers/JobsController.cs +++ b/src/PowderCoating.Web/Controllers/JobsController.cs @@ -4216,9 +4216,69 @@ public class JobsController : Controller return Json(new { success = false, message = "An error occurred. Please try again." }); } } + + /// + /// 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. + /// + [HttpPost] + [ValidateAntiForgeryToken] + public async Task 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(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 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 int JobId { get; set; } diff --git a/src/PowderCoating.Web/Controllers/PaymentController.cs b/src/PowderCoating.Web/Controllers/PaymentController.cs index abfd89e..9e2b8be 100644 --- a/src/PowderCoating.Web/Controllers/PaymentController.cs +++ b/src/PowderCoating.Web/Controllers/PaymentController.cs @@ -127,8 +127,6 @@ public class PaymentController : Controller return BadRequest(new { error = "Invalid payment amount." }); var surcharge = CalculateSurcharge(request.Amount, company!); - var customer = await _context.Customers.AsNoTracking() - .FirstOrDefaultAsync(c => c.Id == invoice.CustomerId); var (success, clientSecret, paymentIntentId, stripeError) = await _stripeConnect.CreatePaymentIntentAsync( @@ -136,7 +134,6 @@ public class PaymentController : Controller invoiceTotal: request.Amount, surchargeAmount: surcharge, currency: "usd", - customerEmail: customer?.Email ?? string.Empty, invoiceNumber: invoice.InvoiceNumber, invoiceId: invoice.Id); @@ -296,7 +293,6 @@ public class PaymentController : Controller var depositAmount = Math.Round(quote!.Total * (quote.DepositPercent / 100m), 2); var surcharge = CalculateSurcharge(depositAmount, company!); - var customerEmail = quote.Customer?.Email ?? quote.ProspectEmail ?? string.Empty; var (success, clientSecret, paymentIntentId, stripeError) = await _stripeConnect.CreateDepositPaymentIntentAsync( @@ -304,7 +300,6 @@ public class PaymentController : Controller depositAmount: depositAmount, surchargeAmount: surcharge, currency: "usd", - customerEmail: customerEmail, quoteNumber: quote.QuoteNumber, quoteId: quote.Id); diff --git a/src/PowderCoating.Web/Controllers/QuotesController.cs b/src/PowderCoating.Web/Controllers/QuotesController.cs index 986ace2..879cd47 100644 --- a/src/PowderCoating.Web/Controllers/QuotesController.cs +++ b/src/PowderCoating.Web/Controllers/QuotesController.cs @@ -3824,6 +3824,49 @@ public class QuotesController : Controller } return (company.LogoData, company.LogoContentType); } + + /// + /// 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. + /// + [HttpPost] + [ValidateAntiForgeryToken] + public async Task 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 @@ -3834,3 +3877,11 @@ public class UpdateQuoteStatusRequest public int QuoteId { 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; } +} diff --git a/src/PowderCoating.Web/Views/Invoices/Details.cshtml b/src/PowderCoating.Web/Views/Invoices/Details.cshtml index f301a80..3079024 100644 --- a/src/PowderCoating.Web/Views/Invoices/Details.cshtml +++ b/src/PowderCoating.Web/Views/Invoices/Details.cshtml @@ -230,21 +230,21 @@ @foreach (var item in Model.InvoiceItems) { - + -
@item.Description
+ @item.Description @if (!string.IsNullOrWhiteSpace(item.ColorName)) { - @item.ColorName + @item.ColorName } @if (!string.IsNullOrWhiteSpace(item.Notes)) { @item.Notes } - @item.Quantity.ToString("G") - @item.UnitPrice.ToString("C") - @item.TotalPrice.ToString("C") + @item.Quantity.ToString("G") + @item.UnitPrice.ToString("C") + @item.TotalPrice.ToString("C") } @@ -257,7 +257,7 @@
Subtotal - @Model.SubTotal.ToString("C") + @Model.SubTotal.ToString("C")
@if (Model.DiscountAmount > 0) { @@ -276,12 +276,12 @@ @Model.SalesTaxAccountName } - @Model.TaxAmount.ToString("C") + @Model.TaxAmount.ToString("C")
}
Total - @Model.Total.ToString("C") + @Model.Total.ToString("C")
@if (Model.AmountPaid > 0) { @@ -291,7 +291,7 @@
Balance Due - @Model.BalanceDue.ToString("C") + @Model.BalanceDue.ToString("C")
} @@ -1442,6 +1442,19 @@ } @section Scripts { + + + + + + diff --git a/src/PowderCoating.Web/Views/CompanySettings/EditTemplate.cshtml b/src/PowderCoating.Web/Views/CompanySettings/EditTemplate.cshtml index 0b877da..cd7a059 100644 --- a/src/PowderCoating.Web/Views/CompanySettings/EditTemplate.cshtml +++ b/src/PowderCoating.Web/Views/CompanySettings/EditTemplate.cshtml @@ -1,6 +1,6 @@ @model PowderCoating.Application.DTOs.Notification.NotificationTemplateDto @{ - ViewData["Title"] = $"Edit Template — {Model.DisplayName}"; + ViewData["Title"] = $"Edit Template — {Model.DisplayName}"; ViewData["PageIcon"] = "bi-envelope-gear"; var placeholders = ViewBag.Placeholders as List<(string Placeholder, string Description)> ?? new List<(string, string)>(); @@ -70,7 +70,7 @@ @if (isEmail) { - +
@@ -131,7 +131,7 @@
+ title="@description — click to copy"> @placeholder Copied! diff --git a/src/PowderCoating.Web/Views/CompanySettings/Index.cshtml b/src/PowderCoating.Web/Views/CompanySettings/Index.cshtml index 9480002..310aec8 100644 --- a/src/PowderCoating.Web/Views/CompanySettings/Index.cshtml +++ b/src/PowderCoating.Web/Views/CompanySettings/Index.cshtml @@ -1,4 +1,4 @@ -@model PowderCoating.Application.DTOs.Company.CompanySettingsDto +@model PowderCoating.Application.DTOs.Company.CompanySettingsDto @{ ViewData["Title"] = "Company Settings"; ViewData["PageIcon"] = "bi-building"; @@ -118,7 +118,7 @@ + data-bs-content="This information appears on every customer-facing document — quotes, invoices, and PDFs. Keep the company name, address, and email accurate so customers see the right details. The <strong>Primary Contact Email</strong> is used as the reply-to address on all outgoing notifications.<br><br><a href='/Help/Settings#company-information' target='_blank'>Learn more →</a>">

@@ -165,39 +165,39 @@ + placeholder="Examples: • We specialise in automotive restoration — wheels, frames, suspension brackets, and roll cages are our bread and butter. • Our customers expect premium pricing. We rarely work on items over 20 sqft. • Most items come to us already stripped; sandblasting adds roughly 15 min per item on average. • We use a 2-stage cure cycle — pre-heat 10 min, coat, cure 20 min at 400°F.">@(Model.OperatingCosts?.AiContextProfile)
- Plain language — write it as if briefing a new estimator on your shop. + Plain language — write it as if briefing a new estimator on your shop. @(Model.OperatingCosts?.AiContextProfile?.Length ?? 0)/2000
@@ -832,7 +832,7 @@ Save AI Profile @@ -843,9 +843,9 @@
How AI Learning Works
-

Layer 1 — Pricing config: Your operating costs (labor, equipment, markup) are always injected automatically.

-

Layer 2 — Your shop profile: The description you write here is added to every AI analysis, guiding estimates toward your typical work.

-

Layer 3 — Automatic learning: Each time your team accepts an AI estimate without changing it, that item is silently added as a calibration example. The AI improves on its own the more you use it.

+

Layer 1 — Pricing config: Your operating costs (labor, equipment, markup) are always injected automatically.

+

Layer 2 — Your shop profile: The description you write here is added to every AI analysis, guiding estimates toward your typical work.

+

Layer 3 — Automatic learning: Each time your team accepts an AI estimate without changing it, that item is silently added as a calibration example. The AI improves on its own the more you use it.

@@ -874,10 +874,10 @@
Used by the AI when estimating job complexity and throughput.
@@ -978,11 +978,11 @@
@@ -1046,7 +1046,7 @@ + data-bs-content="The prefix is combined with a date stamp and sequence number to form record IDs — for example prefix <strong>QT</strong> produces <em>QT-2603-0042</em>. Change the prefix to match your preferred numbering convention. Changing it only affects <strong>new</strong> records; existing numbers are not renamed."> @@ -1082,7 +1082,7 @@ + data-bs-content="Controls how jobs are created and flow through your shop. <strong>Require Customer PO</strong> enforces that a PO number is entered before a job can be saved — useful for commercial accounts. <strong>Allow Customer Approval</strong> enables the approval step in the job workflow — when a quote is approved, the job moves to an Approved status before work begins."> @@ -1141,7 +1141,7 @@ + data-bs-content="Controls which events send emails to your team and customers. Set the <strong>From Email Address</strong> to a domain you control — using a domain-verified address prevents emails landing in spam. If left blank, the system default address is used. Turn off notification types you don't need to avoid inbox noise."> @@ -1311,7 +1311,7 @@ + data-bs-content="Customise the subject and body of every automated email sent by the system — job status updates, quote approvals, invoice reminders, and more. Templates use <strong>{{placeholder}}</strong> tokens that are replaced with live data when the email is sent. Click <strong>Edit</strong> on any row to modify it; use <strong>Reset to Default</strong> to restore the original wording at any time.<br><br>Changes take effect immediately — the next triggered notification will use the updated template."> @@ -1371,7 +1371,7 @@ + data-bs-content="Controls how long records are kept. Most businesses set quote and job retention to <strong>7 years</strong> to satisfy tax and audit requirements. <strong>Deleted record retention</strong> is the grace period after a soft-delete before the record is permanently purged — useful if someone accidentally deletes something."> @@ -1433,7 +1433,7 @@ + data-bs-content="Lookups are the dropdown options that appear throughout the app — job statuses, priorities, quote statuses, and more. You can rename labels, change colours, and reorder them to match your shop's terminology. <strong>Status codes</strong> drive workflow logic and should not be changed unless you understand the impact."> @@ -1869,7 +1869,7 @@ -
Printed in italic at the bottom of every blank work order. Supports plain text — use * or ** for visual emphasis.
+
Printed in italic at the bottom of every blank work order. Supports plain text — use * or ** for visual emphasis.
@@ -2168,7 +2168,7 @@
- +
$ @@ -88,7 +88,7 @@ diff --git a/src/PowderCoating.Web/Views/Invoices/Create.cshtml b/src/PowderCoating.Web/Views/Invoices/Create.cshtml index fbc8ccc..042959c 100644 --- a/src/PowderCoating.Web/Views/Invoices/Create.cshtml +++ b/src/PowderCoating.Web/Views/Invoices/Create.cshtml @@ -5,7 +5,7 @@ ViewData["Title"] = "Create Invoice"; ViewData["PageIcon"] = "bi-receipt"; ViewData["PageHelpTitle"] = "Create Invoice"; - ViewData["PageHelpContent"] = "Invoices start as Drafts — you can freely edit them until you click Send. Once sent, the invoice is locked and the customer is emailed. Line items are pre-populated from the job's items but you can add, edit, or remove any line before sending. Partial payments are supported after sending."; + ViewData["PageHelpContent"] = "Invoices start as Drafts — you can freely edit them until you click Send. Once sent, the invoice is locked and the customer is emailed. Line items are pre-populated from the job's items but you can add, edit, or remove any line before sending. Partial payments are supported after sending."; var jobNumber = ViewBag.JobNumber as string; var customerName = ViewBag.CustomerName as string; var customers = ViewBag.Customers as List; @@ -130,7 +130,7 @@ + data-bs-content="Invoice Date is the date of issue — this is what appears on the printed invoice and determines when payment terms start counting. Due Date drives overdue status and A/R aging reports. Payment Terms is free text (e.g., 'Net 30') that prints on the invoice; it defaults from the customer's settings but you can override it here.">
@@ -143,7 +143,7 @@ + data-bs-content="The date the invoice is issued. This appears on the printed document and is the reference date for payment terms — e.g., Net 30 means payment is due 30 days after this date. Defaults to today.">
@@ -196,7 +196,7 @@ + data-bs-content="Each row is a billable line on the invoice. Pre-populated from the job's items. Qty × Unit Price = Total per line; you can override the Total directly too. Color is optional — it appears under the description on the printed invoice. Add manual lines for anything not in the job (e.g., pickup fee, rush charge)."> @@ -321,7 +321,7 @@ + data-bs-content="Customer Notes appear on the printed and emailed invoice — use these for payment instructions, thank-you messages, or job-specific reminders. Internal Notes are only visible to staff here in the app and never sent to the customer."> @@ -354,7 +354,7 @@ + data-bs-content="Subtotal = sum of all line item totals. Discount is a flat dollar amount deducted before tax — use it for customer-specific deals or courtesy adjustments. Tax % is applied to (Subtotal − Discount). Both default from the company settings but can be overridden per invoice."> @@ -584,7 +584,7 @@ onmousedown="event.preventDefault();merchComboSelect(this)" onmouseenter="this.style.background=document.documentElement.getAttribute('data-bs-theme')==='dark'?'#2c3a5a':'#f0f4ff'" onmouseleave="this.classList.contains('mc-active')?null:this.style.background=''"> - ${i.name}${i.sKU ? ' [' + i.sKU + ']' : ''} — ${formatCurrency(i.defaultPrice)} + ${i.name}${i.sKU ? ' [' + i.sKU + ']' : ''} — ${formatCurrency(i.defaultPrice)} ` ).join('') ).join(''); @@ -656,7 +656,7 @@ } function addGiftCertLineItem(btn) { - // Bootstrap teleports modals to — navigate relative to the button + // Bootstrap teleports modals to — navigate relative to the button const modalEl = btn ? btn.closest('.modal') : document.getElementById('gcModal'); const q = sel => modalEl ? modalEl.querySelector(sel) : document.querySelector(sel); diff --git a/src/PowderCoating.Web/Views/Invoices/Details.cshtml b/src/PowderCoating.Web/Views/Invoices/Details.cshtml index 30bd6e2..1b13309 100644 --- a/src/PowderCoating.Web/Views/Invoices/Details.cshtml +++ b/src/PowderCoating.Web/Views/Invoices/Details.cshtml @@ -17,8 +17,8 @@ var emailOptedOut = hasEmail && !Model.CustomerNotifyByEmail; var smsPhone = !string.IsNullOrWhiteSpace(Model.CustomerMobilePhone) ? Model.CustomerMobilePhone : Model.CustomerPhone; var hasSms = !string.IsNullOrWhiteSpace(smsPhone) && Model.CustomerNotifyBySms; - var showSendModal = hasEmail && !emailOptedOut && hasSms; // both channels — show choice modal - var directSendSms = !hasEmail && hasSms; // SMS only — skip modal + var showSendModal = hasEmail && !emailOptedOut && hasSms; // both channels — show choice modal + var directSendSms = !hasEmail && hasSms; // SMS only — skip modal var hasAvailableCredits = ViewBag.AvailableCreditMemos != null && ((IEnumerable)ViewBag.AvailableCreditMemos).Any(); var canIssueRefund = !isDraft && !isVoided && Model.AmountPaid > 0; var canApplyCredit = !isVoided && Model.BalanceDue > 0 && hasAvailableCredits; @@ -80,7 +80,7 @@
- @Model.CustomerName has no email address on file — you'll be prompted to enter one when sending. + @Model.CustomerName has no email address on file — you'll be prompted to enter one when sending. Add one in customer settings.
@@ -179,12 +179,12 @@

- @(Model.DueDate.HasValue ? Model.DueDate.Value.ToString("MMMM d, yyyy") : "—") + @(Model.DueDate.HasValue ? Model.DueDate.Value.ToString("MMMM d, yyyy") : "—")

-

@(Model.SentDate.HasValue ? Model.SentDate.Value.ToString("MMMM d, yyyy") : "—")

+

@(Model.SentDate.HasValue ? Model.SentDate.Value.ToString("MMMM d, yyyy") : "—")

@if (!string.IsNullOrWhiteSpace(Model.CustomerPO)) { @@ -350,7 +350,7 @@ - @(gcItem.Description.Contains("for ") ? gcItem.Description.Substring(gcItem.Description.IndexOf("for ") + 4).TrimEnd(')') : "—") + @(gcItem.Description.Contains("for ") ? gcItem.Description.Substring(gcItem.Description.IndexOf("for ") + 4).TrimEnd(')') : "—") @gcItem.TotalPrice.ToString("C") @@ -396,7 +396,7 @@ @p.PaymentDate.ToString("MM/dd/yyyy") @p.PaymentMethodDisplay - @(p.Reference ?? "—") + @(p.Reference ?? "—") @if (!string.IsNullOrEmpty(p.DepositAccountName)) { @@ -404,10 +404,10 @@ } else { - + } - @(p.RecordedByName ?? "—") + @(p.RecordedByName ?? "—") @p.Amount.ToString("C") @if (!isVoided) @@ -463,7 +463,7 @@ @r.RefundDate.ToString("MM/dd/yyyy") @r.RefundMethodDisplay @r.Reason - @(r.Reference ?? "—") + @(r.Reference ?? "—") @r.Status (@r.Amount.ToString("C")) @@ -575,7 +575,7 @@ + data-bs-content="Workflow: Edit (Draft only) → Send Invoice (locks it, emails customer) → Record Payment. Partial payments are supported — record multiple payments until fully paid. Void cancels the invoice and reverses the customer balance without deleting history. Delete is only available for Drafts."> @@ -601,7 +601,7 @@ } else if (showSendModal) { - @* Both email + SMS available — let staff choose *@ + @* Both email + SMS available — let staff choose *@ @@ -50,7 +50,7 @@ + data-bs-content="Core job information. Priority and due date are visible on the shop floor board and affect how work is sorted. Customer PO is the customer's own reference number for their purchase order — include it so it appears on invoices. Special Instructions go directly to the shop floor worker."> @@ -70,7 +70,7 @@ + data-bs-content="Controls sort order on the shop floor board and job list. Rush and Urgent jobs are highlighted in red/orange. Normal is the default. Raise priority only when the customer has an actual deadline constraint — overuse of Rush dilutes its meaning for the shop floor team."> @@ -100,7 +100,7 @@ + data-bs-content="The customer's deadline — when the work must be ready for pickup or delivery. Overdue jobs (past due date and not yet completed) are highlighted in red on the job list."> @@ -130,7 +130,7 @@ + data-bs-content="Free-text notes visible to the shop floor worker on the work order. Use this for masking requirements, handling notes, customer preferences, or anything that doesn't fit in the item-level notes — e.g., 'Keep brackets separated, customer allergic to zinc primer'."> @@ -201,7 +201,7 @@ + data-bs-content="Each item represents a physical piece being coated. Use the wizard to pick from the catalog, enter custom dimensions, or upload a photo for AI analysis. Each item gets its own coating specification — color, powder, finish, and cure details. You can add multiple coating passes per item for multi-color or primer+topcoat work."> diff --git a/src/PowderCoating.Web/Views/Jobs/Details.cshtml b/src/PowderCoating.Web/Views/Jobs/Details.cshtml index fbd776d..9334148 100644 --- a/src/PowderCoating.Web/Views/Jobs/Details.cshtml +++ b/src/PowderCoating.Web/Views/Jobs/Details.cshtml @@ -1,4 +1,4 @@ -@model PowderCoating.Application.DTOs.Job.JobDto +@model PowderCoating.Application.DTOs.Job.JobDto @{ ViewData["Title"] = $"Job {Model.JobNumber}"; @@ -2796,7 +2796,7 @@ document.getElementById('costingMargin').textContent = `${d.grossMargin}%`; document.getElementById('costingQuotedMargin').textContent = - d.quotedPrice > 0 ? `${d.quotedMargin}% (quoted ${fmt(d.quotedPrice)})` : '—'; + d.quotedPrice > 0 ? `${d.quotedMargin}% (quoted ${fmt(d.quotedPrice)})` : '—'; // Powder detail lines const pBody = document.getElementById('powderLines'); @@ -2900,9 +2900,9 @@ } function updateTotals(total) { - const fmt = total > 0 ? total.toFixed(2) + ' hrs' : '—'; + const fmt = total > 0 ? total.toFixed(2) + ' hrs' : '—'; document.getElementById('totalHoursDisplay').textContent = fmt; - document.getElementById('timeEntriesTotalHours').textContent = total > 0 ? total.toFixed(2) : '—'; + document.getElementById('timeEntriesTotalHours').textContent = total > 0 ? total.toFixed(2) : '—'; } // -- Modal helpers ------------------------------------------------- diff --git a/src/PowderCoating.Web/Views/Jobs/Edit.cshtml b/src/PowderCoating.Web/Views/Jobs/Edit.cshtml index bb81cb2..b192c85 100644 --- a/src/PowderCoating.Web/Views/Jobs/Edit.cshtml +++ b/src/PowderCoating.Web/Views/Jobs/Edit.cshtml @@ -1,4 +1,4 @@ -@model PowderCoating.Application.DTOs.Job.UpdateJobDto +@model PowderCoating.Application.DTOs.Job.UpdateJobDto @using PowderCoating.Core.Entities @{ @@ -26,7 +26,7 @@ + data-bs-content="Core job information. Priority and due date are visible on the shop floor board and job list. Customer PO is the customer's own reference number — it appears on invoices. Special Instructions go directly to the shop floor worker on the work order."> @@ -45,7 +45,7 @@ + data-bs-content="Tracks where the job is in the workflow: Pending â†' Approved â†' Sandblasting â†' Cleaning â†' Coating â†' Curing â†' QualityCheck â†' Completed â†' ReadyForPickup â†' Delivered. Status changes trigger customer email notifications (if enabled). Use OnHold to pause work without losing progress."> @@ -57,7 +57,7 @@ + data-bs-content="Controls sort order on the shop floor board and job list. Rush and Urgent jobs are highlighted in red/orange. Normal is the default. Raise priority only when the customer has an actual deadline constraint — overuse of Rush dilutes its meaning for the shop floor team."> @@ -85,7 +85,7 @@ + data-bs-content="The customer's deadline — when the work must be ready for pickup or delivery. Overdue jobs (past due date and not yet completed) are highlighted in red on the job list."> @@ -170,7 +170,7 @@ + data-bs-content="Each item represents a physical piece being coated. Use the wizard to pick from the catalog, enter custom dimensions, or upload a photo for AI analysis. Each item gets its own coating specification — color, powder, finish, and cure details. You can add multiple coating passes per item for multi-color or primer+topcoat work."> diff --git a/src/PowderCoating.Web/Views/Jobs/EditItems.cshtml b/src/PowderCoating.Web/Views/Jobs/EditItems.cshtml index 62d0758..7bc84af 100644 --- a/src/PowderCoating.Web/Views/Jobs/EditItems.cshtml +++ b/src/PowderCoating.Web/Views/Jobs/EditItems.cshtml @@ -1,8 +1,8 @@ -@model PowderCoating.Application.DTOs.Job.JobEditItemsViewModel +@model PowderCoating.Application.DTOs.Job.JobEditItemsViewModel @using PowderCoating.Core.Entities @{ - ViewData["Title"] = $"Edit Items — {Model.JobNumber}"; + ViewData["Title"] = $"Edit Items — {Model.JobNumber}"; ViewData["PageIcon"] = "bi-list-check"; } diff --git a/src/PowderCoating.Web/Views/Jobs/Index.cshtml b/src/PowderCoating.Web/Views/Jobs/Index.cshtml index 6be0d17..5553f09 100644 --- a/src/PowderCoating.Web/Views/Jobs/Index.cshtml +++ b/src/PowderCoating.Web/Views/Jobs/Index.cshtml @@ -81,7 +81,7 @@ diff --git a/src/PowderCoating.Web/Views/Jobs/Intake.cshtml b/src/PowderCoating.Web/Views/Jobs/Intake.cshtml index 67a7c0e..2fd937c 100644 --- a/src/PowderCoating.Web/Views/Jobs/Intake.cshtml +++ b/src/PowderCoating.Web/Views/Jobs/Intake.cshtml @@ -1,7 +1,7 @@ @model (PowderCoating.Application.DTOs.Job.JobDto Job, PowderCoating.Application.DTOs.Job.IntakeJobDto Form) @{ - ViewData["Title"] = $"Part Intake — {Model.Job.JobNumber}"; + ViewData["Title"] = $"Part Intake — {Model.Job.JobNumber}"; ViewData["PageIcon"] = "bi-box-seam"; var job = Model.Job; var form = Model.Form; @@ -122,7 +122,7 @@ {
- Count doesn't match expected — note the discrepancy in condition notes. + Count doesn't match expected — note the discrepancy in condition notes.
} @@ -246,7 +246,7 @@ const formData = new FormData(); formData.append('jobId', jobId); formData.append('photo', file); - formData.append('caption', 'Intake — before'); + formData.append('caption', 'Intake — before'); formData.append('photoType', '0'); // JobPhotoType.Before = 0 const token = document.querySelector('input[name="__RequestVerificationToken"]').value; diff --git a/src/PowderCoating.Web/Views/Jobs/ShopDisplay.cshtml b/src/PowderCoating.Web/Views/Jobs/ShopDisplay.cshtml index 6f508aa..50e46e9 100644 --- a/src/PowderCoating.Web/Views/Jobs/ShopDisplay.cshtml +++ b/src/PowderCoating.Web/Views/Jobs/ShopDisplay.cshtml @@ -141,7 +141,7 @@ text-align: center; } - /* bg-info is a light cyan — use dark text so it's readable on TV */ + /* bg-info is a light cyan — use dark text so it's readable on TV */ .badge-xl.bg-info { color: #000 !important; } .worker-badge { @@ -457,7 +457,7 @@ @item.Description @if (item.Colors.Any()) { - + @string.Join(" / ", item.Colors) } @@ -626,7 +626,7 @@ }); diff --git a/src/PowderCoating.Web/Views/Jobs/ShopMobile.cshtml b/src/PowderCoating.Web/Views/Jobs/ShopMobile.cshtml index a03ffc5..1a55ace 100644 --- a/src/PowderCoating.Web/Views/Jobs/ShopMobile.cshtml +++ b/src/PowderCoating.Web/Views/Jobs/ShopMobile.cshtml @@ -445,7 +445,7 @@ } else { - + } @@ -608,7 +608,7 @@ btn.disabled = false; } } catch (e) { - showToast('Network error — try again', 'danger'); + showToast('Network error — try again', 'danger'); btn.innerHTML = originalHtml; btn.disabled = false; } diff --git a/src/PowderCoating.Web/Views/Jobs/StatusBump.cshtml b/src/PowderCoating.Web/Views/Jobs/StatusBump.cshtml index 2a6996d..ecca943 100644 --- a/src/PowderCoating.Web/Views/Jobs/StatusBump.cshtml +++ b/src/PowderCoating.Web/Views/Jobs/StatusBump.cshtml @@ -222,7 +222,7 @@ Due: @job.DueDate.Value.ToString("MMM d, yyyy") @if (job.DueDate < DateTime.Today && !isTerminal) { - — OVERDUE + — OVERDUE } } @@ -237,14 +237,14 @@ } else if (isOnHold) { - @* On hold — offer resume (next logical status after resume by advancing) *@ + @* On hold — offer resume (next logical status after resume by advancing) *@ @if (nextStatus != null) {
@Html.AntiForgeryToken()
} diff --git a/src/PowderCoating.Web/Views/Jobs/WorkOrder.cshtml b/src/PowderCoating.Web/Views/Jobs/WorkOrder.cshtml index 6347b79..4042f30 100644 --- a/src/PowderCoating.Web/Views/Jobs/WorkOrder.cshtml +++ b/src/PowderCoating.Web/Views/Jobs/WorkOrder.cshtml @@ -483,10 +483,10 @@ @coat.Sequence @coat.CoatName - @(coat.ColorName ?? "—") - @(coat.ColorCode ?? "—") - @(coat.Finish ?? "—") - @(coat.VendorName ?? "—") + @(coat.ColorName ?? "—") + @(coat.ColorCode ?? "—") + @(coat.Finish ?? "—") + @(coat.VendorName ?? "—") @if (coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0) { @@ -501,7 +501,7 @@ } else { - + } @@ -622,7 +622,7 @@ } - @* Powder usage QRs — one per unique inventory item *@ + @* Powder usage QRs — one per unique inventory item *@ @if (hasPowderQrs) { @foreach (var pqr in powderQrCodes!) diff --git a/src/PowderCoating.Web/Views/JobsPriority/Index.cshtml b/src/PowderCoating.Web/Views/JobsPriority/Index.cshtml index 533a643..853c112 100644 --- a/src/PowderCoating.Web/Views/JobsPriority/Index.cshtml +++ b/src/PowderCoating.Web/Views/JobsPriority/Index.cshtml @@ -1,4 +1,4 @@ -@model IEnumerable +@model IEnumerable @using PowderCoating.Application.DTOs.Job @using PowderCoating.Core.Entities @using PowderCoating.Core.Enums @@ -51,7 +51,7 @@ - @* ── Carried-Over (Overdue) Jobs ──────────────────────────────────────── *@ + @* -- Carried-Over (Overdue) Jobs ---------------------------------------- *@ @if (overdueJobs.Any()) {
@@ -59,7 +59,7 @@ } - @* ── Scheduled Maintenance for the Day ──────────────────────────────── *@ + @* -- Scheduled Maintenance for the Day -------------------------------- *@
@@ -561,7 +561,7 @@ - @(item.Equipment?.EquipmentName ?? "—") + @(item.Equipment?.EquipmentName ?? "—") @if (!string.IsNullOrEmpty(item.Equipment?.Location)) {
@item.Equipment.Location @@ -1110,14 +1110,14 @@ - + diff --git a/src/PowderCoating.Web/Views/Jobs/Details.cshtml b/src/PowderCoating.Web/Views/Jobs/Details.cshtml index 6b3fd55..eef2c54 100644 --- a/src/PowderCoating.Web/Views/Jobs/Details.cshtml +++ b/src/PowderCoating.Web/Views/Jobs/Details.cshtml @@ -1611,7 +1611,7 @@ }
Items subtotal - @jobPb.ItemsSubtotal.ToString("C") + @jobPb.ItemsSubtotal.ToString("C")
@@ -1660,7 +1660,7 @@
Subtotal - @jobPb.SubtotalBeforeDiscount.ToString("C") + @jobPb.SubtotalBeforeDiscount.ToString("C")
@if (jobPb.DiscountAmount > 0) { @@ -1670,7 +1670,7 @@
After discount - @jobPb.SubtotalAfterDiscount.ToString("C") + @jobPb.SubtotalAfterDiscount.ToString("C")
} @if (jobPb.RushFee > 0) @@ -1684,7 +1684,7 @@ {
Tax (@jobPb.TaxPercent.ToString("G29")%) - @jobPb.TaxAmount.ToString("C") + @jobPb.TaxAmount.ToString("C")
}
@@ -2422,7 +2422,11 @@ patchUrl: '@Url.Action("PatchItem", "Jobs")', canEdit: true, totals: { - finalPrice: '.job-final-price-display' + itemsSubtotal: '[data-pb="itemsSubtotal"]', + subtotalBeforeDiscount: '[data-pb="subtotalBeforeDiscount"]', + subtotalAfterDiscount: '[data-pb="subtotalAfterDiscount"]', + taxAmount: '[data-pb="taxAmount"]', + finalPrice: '.job-final-price-display' } }; diff --git a/src/PowderCoating.Web/Views/Quotes/Details.cshtml b/src/PowderCoating.Web/Views/Quotes/Details.cshtml index 59f5941..4c893f5 100644 --- a/src/PowderCoating.Web/Views/Quotes/Details.cshtml +++ b/src/PowderCoating.Web/Views/Quotes/Details.cshtml @@ -2268,9 +2268,9 @@ patchUrl: '@Url.Action("PatchItem", "Quotes")', canEdit: true, totals: { - subtotal: '#quote-subtotal', - tax: '#quote-tax', - total: '#quote-total' + subtotal: '#quote-subtotal', + taxAmount: '#quote-tax', + total: '#quote-total' } }; diff --git a/src/PowderCoating.Web/wwwroot/js/inline-item-edit.js b/src/PowderCoating.Web/wwwroot/js/inline-item-edit.js index b7d37b0..dffac4f 100644 --- a/src/PowderCoating.Web/wwwroot/js/inline-item-edit.js +++ b/src/PowderCoating.Web/wwwroot/js/inline-item-edit.js @@ -24,17 +24,14 @@ setTimeout(() => el.remove(), 4000); } + // cfg.totals maps response-property-name → CSS selector. + // Each key must exactly match a property returned by the server's PatchItem action. function updateTotals(cfg, 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); }); + Object.entries(t).forEach(([key, selector]) => { + const val = data[key]; + if (selector && val !== undefined && val !== null) { + document.querySelectorAll(selector).forEach(el => { el.textContent = fmt(val); }); } }); } From 600196f679464858923a93a665c24a90894e33f6 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Wed, 20 May 2026 23:03:08 -0400 Subject: [PATCH 18/22] Add ws://localhost:* to dev CSP connect-src for browser refresh aspnetcore-browser-refresh.js uses plain ws:// (not wss://) so it was blocked by the CSP which only listed wss://localhost:*. Both are needed in dev: ws:// for the dotnet watch browser refresh socket, wss:// for SignalR. Co-Authored-By: Claude Sonnet 4.6 --- src/PowderCoating.Web/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PowderCoating.Web/Program.cs b/src/PowderCoating.Web/Program.cs index acb97e9..51f2d8a 100644 --- a/src/PowderCoating.Web/Program.cs +++ b/src/PowderCoating.Web/Program.cs @@ -634,7 +634,7 @@ app.Use(async (context, next) => : "'self' 'unsafe-inline' https://cdn.jsdelivr.net https://code.jquery.com https://js.stripe.com"; var cspConnectSrc = app.Environment.IsDevelopment() - ? "'self' wss://localhost:* https://cdn.jsdelivr.net https://api.stripe.com" // Allow hot reload WebSocket in dev + ? "'self' ws://localhost:* wss://localhost:* https://cdn.jsdelivr.net https://api.stripe.com" // Allow hot reload WebSocket in dev (ws:// for browser-refresh, wss:// for SignalR) : "'self' https://cdn.jsdelivr.net https://api.stripe.com"; context.Response.Headers.Append("Content-Security-Policy", From ec925f9e080bcd7bae9272ebe789614776c74578 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Wed, 20 May 2026 23:09:14 -0400 Subject: [PATCH 19/22] Temp: add console.debug to updateTotals for diagnosis --- src/PowderCoating.Web/wwwroot/js/inline-item-edit.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/PowderCoating.Web/wwwroot/js/inline-item-edit.js b/src/PowderCoating.Web/wwwroot/js/inline-item-edit.js index dffac4f..835fa34 100644 --- a/src/PowderCoating.Web/wwwroot/js/inline-item-edit.js +++ b/src/PowderCoating.Web/wwwroot/js/inline-item-edit.js @@ -28,10 +28,13 @@ // Each key must exactly match a property returned by the server's PatchItem action. function updateTotals(cfg, data) { const t = cfg.totals || {}; + console.debug('[inline-edit] updateTotals data:', data, 'totals cfg:', t); Object.entries(t).forEach(([key, selector]) => { const val = data[key]; + const els = document.querySelectorAll(selector); + console.debug(`[inline-edit] ${key} → "${selector}": val=${val}, elements=${els.length}`); if (selector && val !== undefined && val !== null) { - document.querySelectorAll(selector).forEach(el => { el.textContent = fmt(val); }); + els.forEach(el => { el.textContent = fmt(val); }); } }); } From 1bb07162cd6b51ef5547491dcfce09e8f8edf4fc Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Wed, 20 May 2026 23:56:36 -0400 Subject: [PATCH 20/22] Inline item editing on Job Details with live pricing and costing updates - PatchItem: add case-insensitive JSON deserialization; add legacy fallback that computes a live breakdown from job items when PricingBreakdownJson is null - PatchItem: return itemsSubtotal, subtotalBeforeDiscount, subtotalAfterDiscount, taxAmount in JSON response for immediate DOM updates - GetCostingBreakdown: use job.FinalPrice as revenue (not invoice total) so costing figures reflect inline edits before an invoice exists - Details.cshtml: add data-pb attributes to visible pricing rows; add job-final-price-display class to visible Total element - Details.cshtml: wire afterSave callback to call costing.load() after each edit - inline-item-edit.js: add afterSave hook in commit(); clean up debug logging - Help docs: add Inline Price Editing sections to Jobs, Quotes, and Invoices help articles; add inline editing + job costing revenue notes to AI knowledge base Co-Authored-By: Claude Sonnet 4.6 --- .../Controllers/JobsController.cs | 45 ++++++++++++------- .../Helpers/HelpKnowledgeBase.cs | 8 ++++ .../Views/Help/Invoices.cshtml | 26 +++++++++++ src/PowderCoating.Web/Views/Help/Jobs.cshtml | 29 ++++++++++++ .../Views/Help/Quotes.cshtml | 19 ++++++++ .../Views/Jobs/Details.cshtml | 11 ++--- .../wwwroot/js/inline-item-edit.js | 8 ++-- 7 files changed, 122 insertions(+), 24 deletions(-) diff --git a/src/PowderCoating.Web/Controllers/JobsController.cs b/src/PowderCoating.Web/Controllers/JobsController.cs index 13f404c..d9d7e39 100644 --- a/src/PowderCoating.Web/Controllers/JobsController.cs +++ b/src/PowderCoating.Web/Controllers/JobsController.cs @@ -3983,10 +3983,11 @@ public class JobsController : Controller ovenCost = opCosts.OvenOperatingCostPerHour * defaultOvenCycleHours; } - // 4. Revenue - decimal revenue = job.Invoice != null - ? job.Invoice.Total - : (job.FinalPrice > 0 ? job.FinalPrice : job.QuotedPrice); + // 4. Revenue — prefer FinalPrice (reflects inline edits and job-level changes); + // fall back to Invoice.Total only when FinalPrice is zero (voided/zeroed job). + decimal revenue = job.FinalPrice > 0 + ? job.FinalPrice + : (job.Invoice?.Total ?? job.QuotedPrice); // 5. Rework costs from linked rework jobs var reworkRecords = await _unitOfWork.ReworkRecords.FindAsync( @@ -4022,7 +4023,7 @@ public class JobsController : Controller return Json(new { revenue = Math.Round(revenue, 2), - revenueSource = job.Invoice != null ? "Invoice" : (job.FinalPrice > 0 ? "Final Price" : "Quoted Price"), + revenueSource = job.FinalPrice > 0 ? "Final Price" : (job.Invoice != null ? "Invoice" : "Quoted Price"), powderCost = Math.Round(powderCost, 2), laborCost = Math.Round(laborCost, 2), ovenCost = Math.Round(ovenCost, 2), @@ -4245,10 +4246,13 @@ public class JobsController : Controller var delta = item.TotalPrice - oldTotal; job.FinalPrice = Math.Round(job.FinalPrice + delta, 2); - // Keep the stored pricing snapshot in sync so the breakdown panel stays consistent + // Keep the stored pricing snapshot in sync so the breakdown panel stays consistent. + // Case-insensitive options handle JSON stored before PascalCase serialization was enforced. + QuotePricingBreakdownDto? pbFinal = null; + var jsonOpts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; if (!string.IsNullOrEmpty(job.PricingBreakdownJson)) { - var pb = JsonSerializer.Deserialize(job.PricingBreakdownJson); + var pb = JsonSerializer.Deserialize(job.PricingBreakdownJson, jsonOpts); if (pb != null) { pb.ItemsSubtotal += delta; @@ -4258,24 +4262,35 @@ public class JobsController : Controller pb.Total = Math.Round(pb.SubtotalAfterDiscount + pb.RushFee + pb.TaxAmount, 2); job.FinalPrice = pb.Total; job.PricingBreakdownJson = JsonSerializer.Serialize(pb); + pbFinal = pb; } } await _unitOfWork.Jobs.UpdateAsync(job); await _unitOfWork.CompleteAsync(); - // Deserialize again after possible re-serialization to get final values - QuotePricingBreakdownDto? pbFinal = null; - if (!string.IsNullOrEmpty(job.PricingBreakdownJson)) - pbFinal = JsonSerializer.Deserialize(job.PricingBreakdownJson); + // For legacy jobs without a stored snapshot, derive breakdown from live item totals. + if (pbFinal == null) + { + var allItems = await _unitOfWork.JobItems.FindAsync(ji => ji.JobId == job.Id && !ji.IsDeleted); + var itemsSubtotal = allItems.Sum(ji => ji.TotalPrice); + var subtotal = itemsSubtotal + job.OvenBatchCost + job.ShopSuppliesAmount; + pbFinal = new QuotePricingBreakdownDto + { + ItemsSubtotal = itemsSubtotal, + SubtotalBeforeDiscount = subtotal, + SubtotalAfterDiscount = subtotal, + Total = job.FinalPrice + }; + } return Json(new { lineTotal = item.TotalPrice, finalPrice = job.FinalPrice, - itemsSubtotal = pbFinal?.ItemsSubtotal, - subtotalBeforeDiscount = pbFinal?.SubtotalBeforeDiscount, - subtotalAfterDiscount = pbFinal?.SubtotalAfterDiscount, - taxAmount = pbFinal?.TaxAmount + itemsSubtotal = pbFinal.ItemsSubtotal, + subtotalBeforeDiscount = pbFinal.SubtotalBeforeDiscount, + subtotalAfterDiscount = pbFinal.SubtotalAfterDiscount, + taxAmount = pbFinal.TaxAmount }); } } diff --git a/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs b/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs index 79d09c2..ca0ef10 100644 --- a/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs +++ b/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs @@ -215,6 +215,8 @@ public static class HelpKnowledgeBase **Per-item cost breakdown:** On the Quote Details page, each line item shows a collapsible cost breakdown — click the row to expand it and see how material, labor, equipment, complexity, and markup were calculated for that specific item. This is useful for spotting which items are underpriced or where costs are concentrated. + **Inline item editing on quotes:** On the Quote Details page, any unit price, quantity, or item description can be edited in-place by clicking the value directly. Press Enter or click away to save; press Escape to cancel. The pricing summary (subtotal, discount, tax, and total) updates immediately without reloading the page. + **Pricing Mode (Markup vs Margin):** In Company Settings → Operating Costs you can choose between two pricing modes: - *Markup on Materials* (default) — the General Markup % is applied as a markup on top of calculated costs: `price = cost × (1 + markup%)`. A 25% markup on a $100 cost = $125. - *Target Margin on Total Cost* — the markup % is treated as a target gross margin: `price = cost ÷ (1 − margin%)`. A 25% margin on a $100 cost = $133.33. The difference grows at higher percentages. @@ -305,6 +307,10 @@ public static class HelpKnowledgeBase **Changing the customer on a job:** On the Job Details page, the Customer field is an always-visible dropdown. Select a different customer — a confirmation banner appears. Click **Save** to apply or **Cancel** to revert. Use this to correct a misassigned job or to move a walk-in job to a customer's proper record after they've been added to the system. + **Inline item price editing:** On the Job Details page, any unit price, quantity, or item description can be edited in-place without opening the full edit form. Click the value — it becomes an input field. Type the new value, then press Enter or click away to save (Escape cancels). The pricing summary card (Items Subtotal, Subtotal, Tax, and Total) and the Job Costing card both update immediately without a page reload. + + **Job Costing revenue:** The Job Costing card uses the job's Final Price as the revenue figure — not the linked invoice total. This means inline price edits are reflected in the profit margin estimate immediately, even before an invoice exists. + **Completing a job:** When a job is ready to mark complete, click the **Complete Job** button on the Job Details page. A modal appears asking you to confirm the completion date, actual hours spent, and final price. If the job used powder from inventory, you will be asked to enter the actual lbs used — the modal groups all coats by unique powder color (not per coat or per item) so you fill in one quantity per powder. The system deducts the entered amounts from inventory, crediting any quantities already logged via QR scan. Once confirmed, the job advances to Completed status, and you are prompted to create the invoice if one does not exist. **Creating an invoice from a job:** On the Job Details page, look for the Invoice section and click "Create Invoice." The system pre-fills all line items, pricing, discount, tax rate, payment terms, and due date from the job and customer automatically. Line item descriptions include the coat color(s) for each item (e.g., "Color1 / Color2" if multiple coats), helping customers distinguish repeated items on the invoice. Review the Totals panel on the right — if a discount was applied to the job it will show as a red "Discount Applied" line. Adjust anything you need, then save. @@ -357,6 +363,8 @@ public static class HelpKnowledgeBase **Payment methods:** Cash, Check, Credit/Debit Card, Bank Transfer (ACH), Digital Payment, Store Credit + **Inline item editing on invoices:** On the Invoice Details page, unit prices, quantities, and item descriptions can be edited in-place while the invoice is in Draft status. Click the value — it becomes an input field. Press Enter or click away to save; press Escape to cancel. The invoice total updates immediately. Line items are locked once the invoice is Sent. + **Sending an invoice:** Invoice Details → "Send" — emails PDF to customer. **Online Payments:** [/Invoices/OnlinePayments](/Invoices/OnlinePayments) — Lists invoices with a shareable payment link the customer can pay without logging in. Requires Stripe Connect to be set up first (see below). diff --git a/src/PowderCoating.Web/Views/Help/Invoices.cshtml b/src/PowderCoating.Web/Views/Help/Invoices.cshtml index 7bab77f..3148069 100644 --- a/src/PowderCoating.Web/Views/Help/Invoices.cshtml +++ b/src/PowderCoating.Web/Views/Help/Invoices.cshtml @@ -78,6 +78,32 @@
+
+

+ Inline Price Editing +

+

+ While an invoice is in Draft status, you can edit line item prices, + quantities, and descriptions directly on the Invoice Details page — without + opening the full Edit form. +

+
    +
  1. Click a unit price, quantity, or description cell. The cell turns into an input field.
  2. +
  3. Type the new value.
  4. +
  5. Press Enter or click anywhere outside the field to save. Press Esc to cancel.
  6. +
+

+ The line total and the invoice grand total update immediately without reloading the page. +

+ +
+

Invoice Statuses diff --git a/src/PowderCoating.Web/Views/Help/Jobs.cshtml b/src/PowderCoating.Web/Views/Help/Jobs.cshtml index b53af76..02cb4b3 100644 --- a/src/PowderCoating.Web/Views/Help/Jobs.cshtml +++ b/src/PowderCoating.Web/Views/Help/Jobs.cshtml @@ -309,6 +309,35 @@

+
+

+ Inline Price Editing +

+

+ On the Job Details page you can edit any item’s unit price, quantity, or description + directly in the table — without opening the full Edit form. +

+
    +
  1. Click a unit price, quantity, or description cell in the Items table. The cell turns into an input field.
  2. +
  3. Type the new value.
  4. +
  5. Press Enter or click anywhere outside the field to save. Press Esc to cancel without saving.
  6. +
+

+ After saving, the line total updates immediately and the pricing summary card (Items + Subtotal, Subtotal, Tax, and Grand Total) refreshes — no page reload required. + The Job Costing card also recalculates automatically so your + profit margin estimate stays current. +

+ +
+

Converting from a Quote diff --git a/src/PowderCoating.Web/Views/Help/Quotes.cshtml b/src/PowderCoating.Web/Views/Help/Quotes.cshtml index 2ac014d..7dcfc54 100644 --- a/src/PowderCoating.Web/Views/Help/Quotes.cshtml +++ b/src/PowderCoating.Web/Views/Help/Quotes.cshtml @@ -213,6 +213,25 @@

+
+

+ Inline Price Editing +

+

+ On the Quote Details page you can edit any item’s unit price, quantity, or + description directly in the table without opening the full Edit form. +

+
    +
  1. Click a unit price, quantity, or description cell in the Items table. The cell turns into an input field.
  2. +
  3. Type the new value.
  4. +
  5. Press Enter or click outside the field to save. Press Esc to cancel without saving.
  6. +
+

+ The pricing summary (subtotal, discount, tax, and grand total) updates immediately + — no page reload required. +

+
+

Quote Statuses diff --git a/src/PowderCoating.Web/Views/Jobs/Details.cshtml b/src/PowderCoating.Web/Views/Jobs/Details.cshtml index eef2c54..4bc3845 100644 --- a/src/PowderCoating.Web/Views/Jobs/Details.cshtml +++ b/src/PowderCoating.Web/Views/Jobs/Details.cshtml @@ -1479,7 +1479,7 @@ {
Items Subtotal: - @jobPb.ItemsSubtotal.ToString("C") + @jobPb.ItemsSubtotal.ToString("C")
@if (jobPb.OvenBatchCost > 0) @@ -1508,7 +1508,7 @@
Subtotal: - @jobPb.SubtotalBeforeDiscount.ToString("C") + @jobPb.SubtotalBeforeDiscount.ToString("C")
@if (jobPb.DiscountAmount > 0) @@ -1552,14 +1552,14 @@ {
Tax (@jobPb.TaxPercent.ToString("G29")%): - @jobPb.TaxAmount.ToString("C") + @jobPb.TaxAmount.ToString("C")
}
Total:
-
@jobPb.Total.ToString("C")
+
@jobPb.Total.ToString("C")
@* Collapsible detail breakdown *@ @@ -2427,7 +2427,8 @@ subtotalAfterDiscount: '[data-pb="subtotalAfterDiscount"]', taxAmount: '[data-pb="taxAmount"]', finalPrice: '.job-final-price-display' - } + }, + afterSave: () => { if (typeof costing !== 'undefined') costing.load(); } };