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
@@ -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