From cad728ba6660c6c81c22b03930617adfa2810b2f Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Mon, 27 Apr 2026 13:32:34 -0400 Subject: [PATCH] Fix passkey login tracking, add email opt-out UI guards, and add Quick/Full quote mode toggle - PasskeyController: set LastLoginDate on passkey sign-in so Company Health and audit pages show accurate last-login times (was always showing 'Never') - Jobs/Index status modal: disable 'Notify customer' email toggle and show warning when customer has notifications turned off; CustomerNotifyByEmail added to JobListDto + JobProfile mapping + data-customer-notify attribute - Quotes/Create: disable 'Send quote via email' checkbox with 'Notifications off' badge when selected customer has email opt-out; ViewBag.CustomerEmailOptOutIds added alongside existing CustomerTaxExemptIds pattern - Quotes/Create: Quick Quote / Full Quote segmented toggle at top of form; hides non-essential fields (dates, notes, tags, oven, discount, photos) in Quick mode; selection persisted in localStorage - InvoicesController Send action: improved error logging and user-facing warning when PDF generation or email dispatch fails after status is saved - item-wizard.js: guard item restoration with try/catch; ensure writeHiddenFields always runs on form submit via capture-phase listener - Help docs and AI knowledge base updated for all new features Co-Authored-By: Claude Sonnet 4.6 --- .../DTOs/Job/JobDtos.cs | 1 + .../Mappings/JobProfile.cs | 4 +- .../Controllers/InvoicesController.cs | 9 +- .../Controllers/PasskeyController.cs | 4 + .../Controllers/QuotesController.cs | 5 + .../Helpers/HelpKnowledgeBase.cs | 15 ++- src/PowderCoating.Web/Views/Help/Jobs.cshtml | 5 + .../Views/Help/Quotes.cshtml | 32 ++++++ src/PowderCoating.Web/Views/Jobs/Index.cshtml | 14 +++ .../Views/Quotes/Create.cshtml | 101 ++++++++++++++++-- .../wwwroot/js/item-wizard.js | 22 +++- 11 files changed, 194 insertions(+), 18 deletions(-) diff --git a/src/PowderCoating.Application/DTOs/Job/JobDtos.cs b/src/PowderCoating.Application/DTOs/Job/JobDtos.cs index a1ad86c..e95a0d9 100644 --- a/src/PowderCoating.Application/DTOs/Job/JobDtos.cs +++ b/src/PowderCoating.Application/DTOs/Job/JobDtos.cs @@ -99,6 +99,7 @@ public class JobListDto public string PriorityDisplayName { get; set; } = string.Empty; public string PriorityColorClass { get; set; } = "secondary"; + public bool CustomerNotifyByEmail { get; set; } = true; public DateTime? ScheduledDate { get; set; } public DateTime? DueDate { get; set; } public decimal FinalPrice { get; set; } diff --git a/src/PowderCoating.Application/Mappings/JobProfile.cs b/src/PowderCoating.Application/Mappings/JobProfile.cs index ff20718..4da668b 100644 --- a/src/PowderCoating.Application/Mappings/JobProfile.cs +++ b/src/PowderCoating.Application/Mappings/JobProfile.cs @@ -109,7 +109,9 @@ public class JobProfile : Profile .ForMember(dest => dest.JobPriorityId, opt => opt.MapFrom(src => src.JobPriorityId)) .ForMember(dest => dest.PriorityCode, opt => opt.MapFrom(src => src.JobPriority.PriorityCode)) .ForMember(dest => dest.PriorityDisplayName, opt => opt.MapFrom(src => src.JobPriority.DisplayName)) - .ForMember(dest => dest.PriorityColorClass, opt => opt.MapFrom(src => src.JobPriority.ColorClass)); + .ForMember(dest => dest.PriorityColorClass, opt => opt.MapFrom(src => src.JobPriority.ColorClass)) + .ForMember(dest => dest.CustomerNotifyByEmail, + opt => opt.MapFrom(src => src.Customer == null || src.Customer.NotifyByEmail)); // JobItem mappings CreateMap() diff --git a/src/PowderCoating.Web/Controllers/InvoicesController.cs b/src/PowderCoating.Web/Controllers/InvoicesController.cs index 2aa586a..b43ae8b 100644 --- a/src/PowderCoating.Web/Controllers/InvoicesController.cs +++ b/src/PowderCoating.Web/Controllers/InvoicesController.cs @@ -893,14 +893,19 @@ public class InvoicesController : Controller if (!string.IsNullOrEmpty(invoice.PaymentLinkToken)) paymentUrl = $"{Request.Scheme}://{Request.Host}/pay/{invoice.PaymentLinkToken}"; + bool pdfAndNotifSucceeded = false; try { var pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser!.CompanyId); await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf", paymentUrl); + pdfAndNotifSucceeded = true; } catch (Exception notifyEx) { - _logger.LogWarning(notifyEx, "Invoice sent but notification failed for invoice {Id}", id); + _logger.LogError(notifyEx, + "Invoice {InvoiceId} ({InvoiceNumber}): PDF generation or email dispatch failed. " + + "Inner: {InnerMessage}. Invoice status was already saved as Sent.", + id, invoice.InvoiceNumber, notifyEx.InnerException?.Message ?? "none"); } var notifLog = await _context.NotificationLogs @@ -911,6 +916,8 @@ public class InvoicesController : Controller this.SetNotificationResultToast(notifLog); TempData["Success"] = $"Invoice {invoice.InvoiceNumber} marked as sent."; + if (!pdfAndNotifSucceeded) + TempData["Warning"] = "The invoice is marked as sent, but PDF generation or the customer email failed. Check the notification logs or your email configuration."; return RedirectToAction(nameof(Details), new { id }); } catch (Exception ex) diff --git a/src/PowderCoating.Web/Controllers/PasskeyController.cs b/src/PowderCoating.Web/Controllers/PasskeyController.cs index 134cc5b..486c2a2 100644 --- a/src/PowderCoating.Web/Controllers/PasskeyController.cs +++ b/src/PowderCoating.Web/Controllers/PasskeyController.cs @@ -248,6 +248,10 @@ public class PasskeyController : Controller // Sign in — passkey satisfies both factors; no further 2FA required await _signInManager.SignInAsync(user, isPersistent: false); + // Track login date so CompanyHealth and audit pages show accurate last-login times + user.LastLoginDate = DateTime.UtcNow; + await _userManager.UpdateAsync(user); + _logger.LogInformation("User {UserId} signed in via passkey", user.Id); return Ok(new { redirectUrl = Url.Action("Index", "Dashboard") }); diff --git a/src/PowderCoating.Web/Controllers/QuotesController.cs b/src/PowderCoating.Web/Controllers/QuotesController.cs index b325792..b271181 100644 --- a/src/PowderCoating.Web/Controllers/QuotesController.cs +++ b/src/PowderCoating.Web/Controllers/QuotesController.cs @@ -2642,6 +2642,11 @@ public class QuotesController : Controller .Where(c => c.IsTaxExempt) .Select(c => c.Id) .ToHashSet(); + // Map used by JS to disable the email checkbox when the customer has notifications turned off + ViewBag.CustomerEmailOptOutIds = customers + .Where(c => !c.NotifyByEmail) + .Select(c => c.Id) + .ToHashSet(); // Stored separately so views can restore the company default when switching away from an exempt customer // (ViewBag.CompanyTaxPercent is set by the calling action if it has access to operatingCosts) if (ViewBag.CompanyTaxPercent == null && customers.Any()) diff --git a/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs b/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs index f16f8f9..aae9a06 100644 --- a/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs +++ b/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs @@ -184,12 +184,15 @@ public static class HelpKnowledgeBase - *Expired* — validity period passed - *Converted* — converted into a job + **Quick Quote vs Full Quote mode:** The New Quote form has a toggle at the top — "Quick Quote" hides non-essential fields (dates, notes, tags, oven settings, discounts, photos) so you can get a price in seconds. "Full Quote" shows the complete form. Your selection is remembered automatically. Both modes use the same pricing engine — hidden fields just use defaults. + **How to create a quote:** 1. Go to [Quotes](/Quotes) → "New Quote" - 2. Select existing customer OR enter prospect info (name, email, phone) - 3. Add line items using the item wizard (3 item types below) - 4. Review the pricing breakdown - 5. Save as Draft or Send immediately + 2. Choose Quick Quote (fast) or Full Quote (complete form) using the toggle at the top + 3. Select existing customer OR enter prospect info (name, email, phone) + 4. Add line items using the item wizard (3 item types below) + 5. Review the pricing breakdown + 6. Save as Draft or Send immediately **Three item types in the quote wizard:** 1. *Calculated* — you enter dimensions; system calculates surface area and price from operating costs @@ -220,7 +223,7 @@ public static class HelpKnowledgeBase **Prospect conversion:** If a quote was for a prospect (no existing customer), you can convert them to a customer from the Quote Details page after the quote is approved. - **Sending a quote:** Click "Send" — generates a PDF and emails it to the customer with an online approval link. + **Sending a quote:** Click "Send" — generates a PDF and emails it to the customer with an online approval link. If the customer has email notifications turned off, the send checkbox on the Create page and the Send button on Details are both disabled — a "Notifications off" warning is shown instead. **Customer approval portal:** Customers can approve/reject quotes via a public link (/QuoteApproval) — no login required. @@ -284,6 +287,8 @@ public static class HelpKnowledgeBase **Assigning workers:** Select an assigned shop worker on the Create or Edit page. Worker appears on the Details and Index views. + **Quick status change:** On the Jobs list, click any status badge to open a status-change modal without leaving the page. The modal includes a "Notify customer via email" toggle. If the customer has email notifications turned off, that toggle is automatically disabled and a warning is shown — no email will be sent. + **Job Notes:** Add internal notes on the Job Details page. Notes are private. **Time Entries:** Track labor time on a job from the Details page. diff --git a/src/PowderCoating.Web/Views/Help/Jobs.cshtml b/src/PowderCoating.Web/Views/Help/Jobs.cshtml index c7b28b5..e604772 100644 --- a/src/PowderCoating.Web/Views/Help/Jobs.cshtml +++ b/src/PowderCoating.Web/Views/Help/Jobs.cshtml @@ -77,6 +77,11 @@ (waiting for customer approval, missing materials, etc.) and Cancelled to formally close a job that will not be completed.

+

+ On the Jobs list, click any status badge to open a quick-change modal. The modal includes a + Notify customer via email toggle. If the customer has email notifications turned off, + that toggle is automatically disabled and a warning note is shown — no email will be sent regardless. +

diff --git a/src/PowderCoating.Web/Views/Help/Quotes.cshtml b/src/PowderCoating.Web/Views/Help/Quotes.cshtml index 5408b88..3f69008 100644 --- a/src/PowderCoating.Web/Views/Help/Quotes.cshtml +++ b/src/PowderCoating.Web/Views/Help/Quotes.cshtml @@ -40,6 +40,28 @@

Creating a Quote

+ +

Quick Quote vs Full Quote

+

+ The quote form offers two modes, selectable via the Quick Quote / Full Quote toggle at the + top of the page. Your selection is remembered automatically for next time. +

+
    +
  • Quick Quote — shows only the essentials: customer picker (or walk-in info) and + the item wizard. Dates, notes, tags, oven settings, discounts, and photos are hidden. Use this for + fast phone or counter estimates where you just need a price.
  • +
  • Full Quote — shows the complete form with all fields. Use this for formal + quotes where you want to capture notes, set an expiry date, apply a discount, or add photos.
  • +
+ +

To create a new quote:

  1. Go to Operations › Quotes and click New Quote.
  2. @@ -251,6 +273,16 @@
  3. Click Send Quote. The status changes from Draft to Sent.
  4. If email notifications are configured for your company, the customer will automatically receive an email with the quote details.
+

You can also manually mark a quote as Approved or Rejected when you hear back from the customer verbally or by phone, without going through a formal email send. diff --git a/src/PowderCoating.Web/Views/Jobs/Index.cshtml b/src/PowderCoating.Web/Views/Jobs/Index.cshtml index fae92e3..129e429 100644 --- a/src/PowderCoating.Web/Views/Jobs/Index.cshtml +++ b/src/PowderCoating.Web/Views/Jobs/Index.cshtml @@ -195,6 +195,7 @@ data-job-number="@job.JobNumber" data-status-id="@job.JobStatusId" data-status-name="@job.StatusDisplayName" + data-customer-notify="@job.CustomerNotifyByEmail.ToString().ToLower()" title="Click to change status"> @job.StatusDisplayName @@ -511,6 +512,9 @@ Notify customer via email +