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 <noreply@anthropic.com>
This commit is contained in:
2026-04-27 13:32:34 -04:00
parent 0ea192d55b
commit cad728ba66
11 changed files with 194 additions and 18 deletions
@@ -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; }
@@ -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<JobItem, JobItemDto>()
@@ -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)
@@ -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") });
@@ -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())
@@ -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.
@@ -77,6 +77,11 @@
(waiting for customer approval, missing materials, etc.) and Cancelled to formally close a job that
will not be completed.
</p>
<p>
On the Jobs list, click any status badge to open a quick-change modal. The modal includes a
<strong>Notify customer via email</strong> 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.
</p>
<div class="table-responsive">
<table class="table table-sm table-bordered mb-0">
@@ -40,6 +40,28 @@
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-plus-circle text-primary me-2"></i>Creating a Quote
</h2>
<h3 class="h6 fw-semibold mt-0 mb-2"><i class="bi bi-lightning me-1 text-primary"></i>Quick Quote vs Full Quote</h3>
<p>
The quote form offers two modes, selectable via the <strong>Quick Quote / Full Quote</strong> toggle at the
top of the page. Your selection is remembered automatically for next time.
</p>
<ul class="mb-3">
<li class="mb-1"><strong>Quick Quote</strong> — 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.</li>
<li class="mb-1"><strong>Full Quote</strong> — 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.</li>
</ul>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-3" role="alert">
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
<div>
Switching to Quick Quote does not change how the quote saves — all pricing calculations and the item
wizard work exactly the same. Hidden fields use their default values (no rush fee, no discount, company
default tax rate).
</div>
</div>
<p>To create a new quote:</p>
<ol class="mb-3">
<li class="mb-2">Go to <strong>Operations &rsaquo; Quotes</strong> and click <strong>New Quote</strong>.</li>
@@ -251,6 +273,16 @@
<li class="mb-2">Click <strong>Send Quote</strong>. The status changes from Draft to Sent.</li>
<li class="mb-2">If email notifications are configured for your company, the customer will automatically receive an email with the quote details.</li>
</ol>
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-3" role="alert">
<i class="bi bi-bell-slash flex-shrink-0 mt-1"></i>
<div>
<strong>Customer notifications off:</strong> If a customer has email notifications turned off, the
<strong>Send quote via email</strong> checkbox on the Create page is automatically disabled and marked
with a <em>Notifications off</em> badge. The Send button on the Details page is also disabled.
To re-enable emails for this customer, open their record and turn on <strong>Notify by Email</strong>
under their contact settings.
</div>
</div>
<p>
You can also manually mark a quote as <strong>Approved</strong> or <strong>Rejected</strong> when
you hear back from the customer verbally or by phone, without going through a formal email send.
@@ -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">
<span class="pcl-chip-dot"></span>@job.StatusDisplayName
</span>
@@ -511,6 +512,9 @@
<i class="bi bi-envelope me-1"></i>Notify customer via email
</label>
</div>
<div id="statusModalEmailOptOutNote" class="alert alert-warning alert-permanent py-1 px-2 mt-2 small" style="display:none;">
<i class="bi bi-bell-slash me-1"></i>This customer has email notifications turned off.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
@@ -745,12 +749,22 @@
currentJobStatusId = this.getAttribute('data-status-id');
const jobNumber = this.getAttribute('data-job-number');
const statusName = this.getAttribute('data-status-name');
const customerNotify = this.getAttribute('data-customer-notify') !== 'false';
// Update modal content
document.getElementById('modalStatusJobNumber').textContent = jobNumber;
document.getElementById('modalCurrentStatus').textContent = statusName;
document.getElementById('statusSelect').value = currentJobStatusId;
// Reflect customer email opt-out preference
const emailCheckbox = document.getElementById('statusModalSendEmail');
const emailOptOutNote = document.getElementById('statusModalEmailOptOutNote');
if (emailCheckbox) {
emailCheckbox.disabled = !customerNotify;
if (!customerNotify) emailCheckbox.checked = false;
}
if (emailOptOutNote) emailOptOutNote.style.display = customerNotify ? 'none' : 'block';
// Show modal
const modal = new bootstrap.Modal(document.getElementById('statusModal'));
modal.show();
@@ -20,6 +20,21 @@
@Html.AntiForgeryToken()
<input type="hidden" asp-for="TaxPercent" />
<!-- Mode Toggle -->
<div class="d-flex align-items-center gap-2 mb-3">
<div class="quote-mode-toggle" role="group" aria-label="Quote mode">
<label class="quote-mode-opt">
<input type="radio" name="quoteMode" id="quoteModeQuick" value="simple" autocomplete="off">
<span><i class="bi bi-lightning-fill me-1"></i>Quick Quote</span>
</label>
<label class="quote-mode-opt">
<input type="radio" name="quoteMode" id="quoteModeFull" value="advanced" autocomplete="off">
<span><i class="bi bi-sliders me-1"></i>Full Quote</span>
</label>
</div>
<small class="text-muted fst-italic" id="quoteModeHint"></small>
</div>
<!-- Section 1: Customer / Prospect/Walk-In -->
<div class="card mb-4">
<div class="card-header">
@@ -80,7 +95,7 @@
<span asp-validation-for="ProspectPhone" class="text-danger"></span>
</div>
</div>
<div class="row mt-2">
<div class="row mt-2 quote-advanced-only">
<div class="col-md-6">
<label asp-for="ProspectAddress" class="form-label"></label>
<input asp-for="ProspectAddress" class="form-control" />
@@ -103,7 +118,7 @@
</div>
<!-- Section 2: Quote Information -->
<div class="card mb-4">
<div class="card mb-4 quote-advanced-only">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-info-circle me-2"></i>Quote Information
<a tabindex="0" class="help-icon" role="button"
@@ -167,7 +182,7 @@
</div>
<!-- Section 3: Oven & Batch Pricing -->
<div class="card mb-4">
<div class="card mb-4 quote-advanced-only">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-fire me-2"></i>Oven &amp; Batch Pricing
<a tabindex="0" class="help-icon" role="button"
@@ -242,7 +257,7 @@
</div>
<!-- Section 4: Discount -->
<div class="card mb-4">
<div class="card mb-4 quote-advanced-only">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-tag me-2"></i>Discount <small class="text-muted fw-normal">(optional)</small>
<a tabindex="0" class="help-icon" role="button"
@@ -329,7 +344,7 @@
<!-- Section 6: Quote Photos -->
@if (ViewBag.QuotePhotosEnabled is bool qpe && qpe)
{
<div class="card mb-4" id="quotePhotosCard">
<div class="card mb-4 quote-advanced-only" id="quotePhotosCard">
<div class="card-header bg-white d-flex align-items-center justify-content-between">
<h5 class="mb-0"><i class="bi bi-images me-2 text-secondary"></i>Photos <span class="badge bg-secondary ms-1" id="stagedPhotoCount">0</span></h5>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="document.getElementById('quotePhotoStagingInput').click()">
@@ -360,6 +375,10 @@
<i class="bi bi-envelope me-1"></i>Send quote via email
</label>
</div>
<span id="emailOptOutNote" class="badge bg-warning text-dark ms-1" style="display:none;"
title="This customer has email notifications turned off">
<i class="bi bi-bell-slash me-1"></i>Notifications off
</span>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="top"
data-bs-title="Send via Email"
@@ -538,6 +557,7 @@
"taxPercent": @Model.TaxPercent,
"companyTaxPercent": @((ViewBag.CompanyTaxPercent ?? Model.TaxPercent).ToString()),
"taxExemptCustomerIds": @Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.CustomerTaxExemptIds ?? new System.Collections.Generic.HashSet<int>())),
"emailOptOutCustomerIds": @Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.CustomerEmailOptOutIds ?? new System.Collections.Generic.HashSet<int>())),
"discountType": @Json.Serialize(Model.DiscountType),
"discountValue": @Model.DiscountValue,
"isRushJob": @Json.Serialize(Model.IsRushJob),
@@ -556,6 +576,36 @@
@section Styles {
<link rel="stylesheet" href="~/lib/tom-select/css/tom-select.bootstrap5.min.css">
<style>
/* Quick / Full quote mode toggle */
#quoteForm.quote-simple-mode .quote-advanced-only { display: none !important; }
.quote-mode-toggle {
display: inline-flex;
background: var(--bs-tertiary-bg);
border: 1px solid var(--bs-border-color);
border-radius: 999px;
padding: 3px;
gap: 2px;
}
.quote-mode-opt { margin: 0; }
.quote-mode-opt input { display: none; }
.quote-mode-opt span {
display: block;
padding: 4px 16px;
border-radius: 999px;
cursor: pointer;
font-size: .85rem;
font-weight: 600;
color: var(--bs-secondary-color);
transition: background .15s, color .15s, box-shadow .15s;
user-select: none;
}
.quote-mode-opt input:checked + span {
background: #0d6efd;
color: #fff;
box-shadow: 0 1px 4px rgba(13,110,253,.35);
}
.quote-mode-opt span:hover { color: var(--bs-body-color); }
.quote-mode-opt input:checked + span:hover { color: #fff; }
/* Wizard step indicator */
.wizard-step-dot {
width: 22px; height: 22px; border-radius: 50%;
@@ -604,6 +654,34 @@
<script src="~/lib/tom-select/js/tom-select.complete.min.js"></script>
<script src="~/js/item-wizard.js?v=@DateTime.Now.Ticks"></script>
<script>
// ── Quick / Full quote mode toggle ──────────────────────────────────
(function () {
const STORAGE_KEY = 'pcl_quote_mode';
const form = document.getElementById('quoteForm');
const hint = document.getElementById('quoteModeHint');
function applyMode(mode) {
if (mode === 'simple') {
form.classList.add('quote-simple-mode');
hint.textContent = 'Advanced fields are hidden — switch to Full Quote to see them.';
} else {
form.classList.remove('quote-simple-mode');
hint.textContent = '';
}
}
const saved = localStorage.getItem(STORAGE_KEY) || 'advanced';
document.getElementById(saved === 'simple' ? 'quoteModeQuick' : 'quoteModeFull').checked = true;
applyMode(saved);
document.querySelectorAll('input[name="quoteMode"]').forEach(function (radio) {
radio.addEventListener('change', function () {
localStorage.setItem(STORAGE_KEY, this.value);
applyMode(this.value);
});
});
})();
document.addEventListener('DOMContentLoaded', function () {
initTagInput('quoteTags', 'quoteTagsContainer');
new TomSelect('#customerSelect', {
@@ -616,15 +694,26 @@
});
});
// Update tax rate when customer changes to/from a tax-exempt customer
// Update tax rate and email opt-out state when customer changes
function onQuoteCustomerChanged(select) {
const meta = JSON.parse(document.getElementById('quoteMetaData').textContent);
const exemptIds = new Set(meta.taxExemptCustomerIds || []);
const optOutIds = new Set(meta.emailOptOutCustomerIds || []);
const customerId = parseInt(select.value) || 0;
const taxField = document.querySelector('[name="TaxPercent"]');
if (taxField) {
taxField.value = exemptIds.has(customerId) ? 0 : (meta.companyTaxPercent ?? meta.taxPercent);
}
const emailCheckbox = document.getElementById('SendEmailToCustomer');
const emailNote = document.getElementById('emailOptOutNote');
if (emailCheckbox) {
const optedOut = optOutIds.has(customerId);
emailCheckbox.disabled = optedOut;
if (optedOut) emailCheckbox.checked = false;
if (emailNote) emailNote.style.display = optedOut ? 'inline' : 'none';
}
}
// Toggle customer / prospect sections
@@ -65,11 +65,23 @@ document.addEventListener('DOMContentLoaded', () => {
// Restore items from server round-trip (validation failure re-render)
const existingEl = document.getElementById('existingItemsData');
if (existingEl) {
quoteItems = JSON.parse(existingEl.textContent);
renderAllCards();
writeHiddenFields();
window.scrollTo({ top: 0, behavior: 'instant' });
scheduleAutoPricing();
try {
quoteItems = JSON.parse(existingEl.textContent);
renderAllCards();
writeHiddenFields();
window.scrollTo({ top: 0, behavior: 'instant' });
scheduleAutoPricing();
} catch (err) {
console.error('item-wizard: failed to restore items from server model:', err);
}
}
// Guarantee hidden fields are always written on form submission, even if the wizard
// was never interacted with (e.g. validation round-trip with pre-existing items).
const hfc = document.getElementById('hiddenFieldsContainer');
const ownerForm = hfc?.closest('form');
if (ownerForm) {
ownerForm.addEventListener('submit', writeHiddenFields, { capture: true });
}
// Close any open powder combobox dropdown when clicking outside it