Hide email controls when no email on file; show SMS hint for quote/job events

- Quotes Create/Edit: hide 'Send via email' checkbox when customer has no
  email; show badge 'send via SMS from details' or 'SMS consent required'
  when customer has a mobile number. JS responds to customer dropdown change.
- Quotes Details: hide 'Send Quote via Email' button and approval email
  checkbox; hide SMS button when no mobile; show consent-required note.
- Jobs Details (Mark Complete modal): hide email checkbox; show
  'SMS notification will be sent' badge or consent-required note.
- Jobs Index (status modal): hide email row when customer has no email.
- Jobs Edit: hide 'Notify customer if status changes' when no email.
- Invoices Details: hide Send/Re-send buttons when no email (vs. disabled).

DTOs: added CustomerEmail + CustomerNotifyByEmail to JobDto/JobListDto;
added CustomerNotifyByEmail/CustomerMobilePhone/CustomerNotifyBySms to
QuoteDto. Mapped in JobProfile and QuotesController customer blocks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 17:32:08 -04:00
parent d3863c713b
commit acbd9f60be
1190 changed files with 338016 additions and 88 deletions
@@ -376,18 +376,21 @@
<!-- Form Actions -->
<div class="card mb-4">
<div class="card-body d-flex align-items-center justify-content-end flex-wrap gap-3">
<div class="d-flex align-items-center gap-1">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" role="switch"
id="SendEmailToCustomer" name="SendEmailToCustomer" value="true" />
<label class="form-check-label fw-semibold" for="SendEmailToCustomer">
<i class="bi bi-envelope me-1"></i>Send quote via email
</label>
<div class="d-flex align-items-center gap-1" id="notifyCustomerSection">
<div id="emailNotifySection">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" role="switch"
id="SendEmailToCustomer" name="SendEmailToCustomer" value="true" />
<label class="form-check-label fw-semibold" for="SendEmailToCustomer">
<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>
</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>
<span id="smsNotifyNote" style="display:none;"></span>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="top"
data-bs-title="Send via Email"
@@ -567,6 +570,8 @@
"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>())),
"noEmailCustomerIds": @Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.CustomerNoEmailIds ?? new System.Collections.Generic.HashSet<int>())),
"smsConsentCustomerIds": @Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.CustomerSmsConsentIds ?? new System.Collections.Generic.HashSet<int>())),
"discountType": @Json.Serialize(Model.DiscountType),
"discountValue": @Model.DiscountValue,
"isRushJob": @Json.Serialize(Model.IsRushJob),
@@ -703,11 +708,13 @@
});
});
// Update tax rate and email opt-out state when customer changes
// Update tax rate, email visibility, and SMS note 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 noEmailIds = new Set(meta.noEmailCustomerIds || []);
const smsConsentIds = new Set(meta.smsConsentCustomerIds || []);
const customerId = parseInt(select.value) || 0;
const taxField = document.querySelector('[name="TaxPercent"]');
@@ -715,13 +722,34 @@
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';
const noEmail = customerId > 0 && noEmailIds.has(customerId);
const emailSection = document.getElementById('emailNotifySection');
const smsNote = document.getElementById('smsNotifyNote');
if (emailSection) emailSection.style.display = noEmail ? 'none' : '';
if (!noEmail) {
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';
}
}
if (smsNote) {
if (noEmail && customerId > 0) {
const hasSms = smsConsentIds.has(customerId);
smsNote.style.display = 'inline';
smsNote.className = hasSms ? 'badge bg-info text-white' : 'badge bg-warning text-dark';
smsNote.innerHTML = hasSms
? '<i class="bi bi-phone me-1"></i>No email — send via SMS from quote details'
: '<i class="bi bi-phone-slash me-1"></i>No email — SMS consent required';
} else {
smsNote.style.display = 'none';
}
}
}
@@ -1524,29 +1524,53 @@
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-outline-secondary">
<i class="bi bi-pencil me-1"></i>Edit Quote
</a>
@{
var detHasEmail = !string.IsNullOrWhiteSpace(Model.CustomerEmail);
var detHasMobile = !string.IsNullOrWhiteSpace(Model.CustomerMobilePhone);
var detHasSmsConsent = Model.CustomerNotifyBySms && detHasMobile;
}
@if (Model.StatusCode != "APPROVED" && Model.StatusCode != "CONVERTED")
{
<form asp-action="ApproveQuote" asp-route-id="@Model.Id" method="post" id="approveQuoteForm">
@Html.AntiForgeryToken()
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" role="switch"
id="approveQuoteSendEmail" name="sendEmail" value="true"
@(ViewBag.EmailDefaultOnApprove == true ? "checked" : "") />
<label class="form-check-label small" for="approveQuoteSendEmail">
<i class="bi bi-envelope me-1"></i>Notify customer via email
</label>
</div>
@if (detHasEmail)
{
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" role="switch"
id="approveQuoteSendEmail" name="sendEmail" value="true"
@(ViewBag.EmailDefaultOnApprove == true ? "checked" : "") />
<label class="form-check-label small" for="approveQuoteSendEmail">
<i class="bi bi-envelope me-1"></i>Notify customer via email
</label>
</div>
}
<button type="button" class="btn btn-outline-success w-100" data-bs-toggle="modal" data-bs-target="#approveQuoteModal">
<i class="bi bi-check-circle me-1"></i>Approve Quote
</button>
</form>
}
<button type="button" class="btn btn-outline-primary" onclick="resendQuote(@Model.Id)">
<i class="bi bi-envelope-arrow-up me-1"></i>Send Quote via Email
</button>
<button type="button" class="btn btn-outline-info" onclick="sendQuoteSms(@Model.Id)">
<i class="bi bi-chat-dots me-1"></i>Send Quote via SMS
</button>
@if (detHasEmail)
{
<button type="button" class="btn btn-outline-primary" onclick="resendQuote(@Model.Id)">
<i class="bi bi-envelope-arrow-up me-1"></i>Send Quote via Email
</button>
}
@if (detHasMobile)
{
<button type="button" class="btn btn-outline-info" onclick="sendQuoteSms(@Model.Id)">
<i class="bi bi-chat-dots me-1"></i>Send Quote via SMS
</button>
}
@if (!detHasMobile && !detHasEmail)
{
<div class="alert alert-warning alert-permanent py-1 px-2 small">
<i class="bi bi-exclamation-triangle me-1"></i>No email or phone — update the customer record to send this quote.
</div>
}
@if (detHasMobile && !detHasSmsConsent)
{
<div class="text-muted small"><i class="bi bi-phone-slash me-1"></i>SMS consent required to send via text.</div>
}
@if (!Model.ConvertedToJobId.HasValue)
{
<form asp-action="ConvertToJob" asp-route-id="@Model.Id" method="post" class="d-inline" id="createJobForm">
+76 -8
View File
@@ -403,12 +403,37 @@
<!-- Form Actions -->
<div class="card mb-4">
<div class="card-body d-flex align-items-center justify-content-end flex-wrap gap-3">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" role="switch"
id="SendEmailToCustomer" name="SendEmailToCustomer" value="true" />
<label class="form-check-label fw-semibold" for="SendEmailToCustomer">
<i class="bi bi-envelope me-1"></i>Send updated quote via email
</label>
@{
var editHasEmail = !string.IsNullOrWhiteSpace(ViewBag.CustomerEmail as string);
var editHasSms = (bool)(ViewBag.CustomerNotifyBySms ?? false) && !string.IsNullOrWhiteSpace(ViewBag.CustomerMobilePhone as string);
}
<div class="d-flex align-items-center gap-1" id="notifyCustomerSection">
<div id="emailNotifySection" style="@(editHasEmail ? "" : "display:none;")">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" role="switch"
id="SendEmailToCustomer" name="SendEmailToCustomer" value="true" />
<label class="form-check-label fw-semibold" for="SendEmailToCustomer">
<i class="bi bi-envelope me-1"></i>Send updated quote via email
</label>
</div>
</div>
@if (!editHasEmail)
{
<span id="smsNotifyNote" class="badge @(editHasSms ? "bg-info text-white" : "bg-warning text-dark")">
@if (editHasSms)
{
<i class="bi bi-phone me-1"></i><text>No email — send via SMS from quote details</text>
}
else
{
<i class="bi bi-phone-slash me-1"></i><text>No email — SMS consent required</text>
}
</span>
}
else
{
<span id="smsNotifyNote" style="display:none;"></span>
}
</div>
<a asp-action="Details" asp-controller="Quotes" asp-route-id="@Model.Id" class="btn btn-outline-secondary btn-lg">
<i class="bi bi-x-circle me-1"></i>Cancel
@@ -601,7 +626,10 @@
"aiAnalyzeUrl": "@Url.Action("AiAnalyzeItem", "Quotes")",
"aiPhotoQuotesEnabled": @Json.Serialize((bool)(ViewBag.AiPhotoQuotesEnabled ?? true)),
"itemsFieldPrefix": "QuoteItems",
"aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")"
"aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")",
"emailOptOutCustomerIds": @Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.CustomerEmailOptOutIds ?? new System.Collections.Generic.HashSet<int>())),
"noEmailCustomerIds": @Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.CustomerNoEmailIds ?? new System.Collections.Generic.HashSet<int>())),
"smsConsentCustomerIds": @Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.CustomerSmsConsentIds ?? new System.Collections.Generic.HashSet<int>()))
}
</script>
@@ -652,9 +680,49 @@
document.addEventListener('DOMContentLoaded', function () {
initTagInput('quoteTags', 'quoteTagsContainer');
var custEl = document.getElementById('customerSelect');
if (custEl) new TomSelect(custEl, { placeholder: '-- Select Customer --', openOnFocus: true, maxOptions: false });
if (custEl) new TomSelect(custEl, { placeholder: '-- Select Customer --', openOnFocus: true, maxOptions: false,
onChange: function(value) { onQuoteCustomerChanged({ value: value }); }
});
});
function onQuoteCustomerChanged(select) {
const meta = JSON.parse(document.getElementById('quoteMetaData').textContent);
const optOutIds = new Set(meta.emailOptOutCustomerIds || []);
const noEmailIds = new Set(meta.noEmailCustomerIds || []);
const smsConsentIds = new Set(meta.smsConsentCustomerIds || []);
const customerId = parseInt(select.value) || 0;
const noEmail = customerId > 0 && noEmailIds.has(customerId);
const emailSection = document.getElementById('emailNotifySection');
const smsNote = document.getElementById('smsNotifyNote');
if (emailSection) emailSection.style.display = noEmail ? 'none' : '';
if (!noEmail) {
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';
}
}
if (smsNote) {
if (noEmail && customerId > 0) {
const hasSms = smsConsentIds.has(customerId);
smsNote.style.display = 'inline';
smsNote.className = hasSms ? 'badge bg-info text-white' : 'badge bg-warning text-dark';
smsNote.innerHTML = hasSms
? '<i class="bi bi-phone me-1"></i>No email — send via SMS from quote details'
: '<i class="bi bi-phone-slash me-1"></i>No email — SMS consent required';
} else {
smsNote.style.display = 'none';
}
}
}
// Discount type toggle
function onDiscountTypeChange() {
const type = document.getElementById('discountTypeSelect').value;