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