Add TCPA-compliant SMS consent tracking for prospect quotes

- Quote entity: ProspectSmsConsent (bool) + ProspectSmsConsentedAt (DateTime?) fields
- QuoteDtos: consent fields on Create/Update/Convert DTOs with TCPA guidance text
- Quote Create/Edit views: SMS consent checkbox shown when mobile number is entered
- Quote ConvertToCustomer view: staff must re-confirm consent carries over to customer record
- QuoteApproval: consent state exposed in ViewModel and ApprovalPage for transparency
- Consent timestamp cleared when prospect quote is linked to an existing customer
- Migration: AddProspectSmsConsent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-08 20:47:04 -04:00
parent fb979bc88d
commit f40d58ac2e
10 changed files with 9776 additions and 3 deletions
@@ -454,6 +454,10 @@ public class QuoteApprovalController : Controller
CustomerEmail = quote.Customer?.Email ?? quote.ProspectEmail,
ExpirationDate = quote.ExpirationDate,
ApprovalTokenExpiresAt = quote.ApprovalTokenExpiresAt,
ItemsSubtotal = quote.ItemsSubtotal,
OvenBatchCost = quote.OvenBatchCost,
ShopSuppliesAmount = quote.ShopSuppliesAmount,
ShopSuppliesPercent = quote.ShopSuppliesPercent,
SubTotal = quote.SubTotal,
DiscountAmount = quote.DiscountAmount,
HideDiscountFromCustomer = quote.HideDiscountFromCustomer,
@@ -12,6 +12,10 @@ public class QuoteApprovalViewModel
public string? CustomerEmail { get; set; }
public DateTime? ExpirationDate { get; set; }
public DateTime? ApprovalTokenExpiresAt { get; set; }
public decimal ItemsSubtotal { get; set; }
public decimal OvenBatchCost { get; set; }
public decimal ShopSuppliesAmount { get; set; }
public decimal ShopSuppliesPercent { get; set; }
public decimal SubTotal { get; set; }
public decimal DiscountAmount { get; set; }
public bool HideDiscountFromCustomer { get; set; }
@@ -90,9 +90,30 @@
<div class="row justify-content-end">
<div class="col-sm-6 col-md-5">
<div class="d-flex justify-content-between mb-1">
<span class="text-muted">Subtotal</span>
<span>@Model.SubTotal.ToString("C")</span>
<span class="text-muted">Items Subtotal</span>
<span>@Model.ItemsSubtotal.ToString("C")</span>
</div>
@if (Model.OvenBatchCost > 0)
{
<div class="d-flex justify-content-between mb-1">
<span class="text-muted">Oven Processing</span>
<span>@Model.OvenBatchCost.ToString("C")</span>
</div>
}
@if (Model.ShopSuppliesAmount > 0)
{
<div class="d-flex justify-content-between mb-1">
<span class="text-muted">Shop Supplies (@(Model.ShopSuppliesPercent.ToString("0.##"))%)</span>
<span>@Model.ShopSuppliesAmount.ToString("C")</span>
</div>
}
@if (Model.OvenBatchCost > 0 || Model.ShopSuppliesAmount > 0)
{
<div class="d-flex justify-content-between mb-1 fw-semibold">
<span>Subtotal</span>
<span>@Model.SubTotal.ToString("C")</span>
</div>
}
@if (Model.DiscountAmount > 0 && !Model.HideDiscountFromCustomer)
{
<div class="d-flex justify-content-between mb-1 text-success">
@@ -207,6 +207,47 @@
</div>
</div>
<!-- SMS Notifications -->
<div class="card mb-4 border-info">
<div class="card-header bg-info bg-opacity-10">
<h5 class="mb-0">
<i class="bi bi-phone me-2"></i>SMS Notifications
</h5>
</div>
<div class="card-body">
<input type="hidden" asp-for="ProspectSmsConsentedAt" />
@if (Model.SmsConsent)
{
<div class="alert alert-success alert-permanent mb-3 py-2">
<i class="bi bi-check-circle me-1"></i>
<strong>SMS consent was recorded on the quote</strong>
@if (Model.ProspectSmsConsentedAt.HasValue)
{
<span>on @Model.ProspectSmsConsentedAt.Value.ToLocalTime().ToString("MM/dd/yyyy")</span>
}
</div>
}
else
{
<div class="alert alert-warning alert-permanent mb-3 py-2">
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>No SMS consent was recorded on the quote.</strong>
</div>
}
<div class="form-check">
<input asp-for="SmsConsent" class="form-check-input" id="SmsConsent" />
<label class="form-check-label fw-semibold" for="SmsConsent">
Customer has given verbal consent to receive SMS notifications
</label>
</div>
<small class="text-muted d-block mt-1">
<i class="bi bi-shield-check me-1"></i>
Only check if the customer has explicitly agreed to receive text messages (TCPA compliance).
If checked, the new customer record will have SMS enabled and a confirmation text will be sent.
</small>
</div>
</div>
<!-- Form Actions -->
<div class="mb-4">
<button type="submit" class="btn btn-success btn-lg">
@@ -104,6 +104,19 @@
<span asp-validation-for="ProspectPhone" class="text-danger"></span>
</div>
</div>
<div class="row mt-2" id="prospectSmsConsentRow" style="display:none;">
<div class="col-12">
<div class="form-check">
<input asp-for="ProspectSmsConsent" class="form-check-input" id="ProspectSmsConsent" />
<label class="form-check-label fw-semibold" for="ProspectSmsConsent">
Customer verbally agreed to receive SMS notifications
</label>
</div>
<small class="text-muted">
<i class="bi bi-shield-check me-1"></i>Only check if the customer explicitly consented to receive text messages (TCPA compliance).
</small>
</div>
</div>
<div class="row mt-2 quote-advanced-only">
<div class="col-md-6">
<label asp-for="ProspectAddress" class="form-label"></label>
@@ -764,8 +777,23 @@
if (el) el.removeAttribute('required');
});
}
updateProspectSmsConsentVisibility();
}
function updateProspectSmsConsentVisibility() {
const phoneEl = document.getElementById('prospectPhone');
const row = document.getElementById('prospectSmsConsentRow');
if (!phoneEl || !row) return;
const isProspect = document.getElementById('forProspect')?.checked;
row.style.display = (isProspect && phoneEl.value.trim()) ? '' : 'none';
}
document.addEventListener('DOMContentLoaded', function () {
const phoneEl = document.getElementById('prospectPhone');
if (phoneEl) phoneEl.addEventListener('input', updateProspectSmsConsentVisibility);
updateProspectSmsConsentVisibility();
});
// Discount type toggle
function onDiscountTypeChange() {
const type = document.getElementById('discountTypeSelect').value;
+26 -1
View File
@@ -52,10 +52,23 @@
</div>
<div class="col-md-6">
<label asp-for="ProspectPhone" class="form-label">Phone <span class="text-danger">*</span></label>
<input asp-for="ProspectPhone" class="form-control" type="tel" />
<input asp-for="ProspectPhone" class="form-control" type="tel" id="editProspectPhone" />
<span asp-validation-for="ProspectPhone" class="text-danger"></span>
</div>
</div>
<div class="row mt-2" id="editProspectSmsConsentRow">
<div class="col-12">
<div class="form-check">
<input asp-for="ProspectSmsConsent" class="form-check-input" id="ProspectSmsConsent" />
<label class="form-check-label fw-semibold" for="ProspectSmsConsent">
Customer verbally agreed to receive SMS notifications
</label>
</div>
<small class="text-muted">
<i class="bi bi-shield-check me-1"></i>Only check if the customer explicitly consented to receive text messages (TCPA compliance).
</small>
</div>
</div>
<div class="row mt-2">
<div class="col-md-6">
<label asp-for="ProspectAddress" class="form-label"></label>
@@ -683,8 +696,20 @@
if (custEl) new TomSelect(custEl, { placeholder: '-- Select Customer --', openOnFocus: true, maxOptions: false,
onChange: function(value) { onQuoteCustomerChanged({ value: value }); }
});
// Show/hide SMS consent row based on phone field value
const phoneEl = document.getElementById('editProspectPhone');
if (phoneEl) {
phoneEl.addEventListener('input', updateEditProspectSmsConsent);
updateEditProspectSmsConsent();
}
});
function updateEditProspectSmsConsent() {
const phoneEl = document.getElementById('editProspectPhone');
const row = document.getElementById('editProspectSmsConsentRow');
if (phoneEl && row) row.style.display = phoneEl.value.trim() ? '' : 'none';
}
function onQuoteCustomerChanged(select) {
const meta = JSON.parse(document.getElementById('quoteMetaData').textContent);
const optOutIds = new Set(meta.emailOptOutCustomerIds || []);