Ad-hoc quote email, accounting improvements, AI lookup fix, and misc service updates

- Quotes: ad-hoc email modal on Quote Details lets staff send to an address not on file;
  QuotesController passes overrideEmail through to NotificationService
- Quotes/Details view: SMS consent display, email/SMS send button state based on consent
- Accounting module: AccountingDisplayHelpers for consistent ledger formatting;
  AccountsController + Accounts views improvements; AccountingEnums additions
- Bills/Expenses: AI account categorization fixes in BillsController and ExpensesController
- InventoryAiLookupService: TDS cure fallback no longer fires on AiAugmentFromUrl path
  (LookupByUrlAsync already has it built in — was double-fetching)
- PdfService: quote/invoice PDF updates
- PricingCalculationService: minor pricing logic fix
- QuoteProfile: mapping updates for new quote fields
- ApplicationDbContextModelSnapshot: catches up to all 4 migrations in this branch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-08 20:48:00 -04:00
parent 0d980e651a
commit 9a52e7fae5
19 changed files with 480 additions and 63 deletions
@@ -154,7 +154,7 @@
(function () {
// SubType enum values → AccountType enum values (mirrors server-side mapping)
const subTypeToAccountType = {
1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, // Assets
8: 1, 1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, // Assets
10: 2, 11: 2, 12: 2, 13: 2, // Liabilities
20: 3, 21: 3, // Equity
30: 4, 31: 4, 32: 4, // Revenue
@@ -144,7 +144,7 @@
<script>
// Auto-set AccountType when SubType is changed
const subTypeToAccountType = {
1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, // Asset
8: 1, 1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, // Asset
10: 2, 11: 2, 12: 2, 13: 2, // Liability
20: 3, 21: 3, // Equity
30: 4, 31: 4, 32: 4, // Revenue
@@ -156,7 +156,7 @@
<span class="badge bg-secondary ms-1" title="System account — cannot be deleted">sys</span>
}
</td>
<td><span class="text-muted small">@acct.AccountSubType</span></td>
<td><span class="text-muted small">@acct.AccountSubType.ToDisplayName()</span></td>
<td>
@if (!string.IsNullOrEmpty(acct.ParentAccountName))
{
@@ -29,10 +29,11 @@
_ => "bi-journal"
};
string typeLabel = Model.AccountType == AccountType.CostOfGoods ? "Cost of Goods Sold" : Model.AccountType.ToString();
string typeLabel = Model.AccountType.ToDisplayName();
// Derive from AccountSubType (more reliable than AccountType which users can misconfigure)
bool normalDebitBalance =
Model.AccountSubType == AccountSubType.Cash ||
Model.AccountSubType == AccountSubType.Checking ||
Model.AccountSubType == AccountSubType.Savings ||
Model.AccountSubType == AccountSubType.AccountsReceivable ||
@@ -71,7 +72,7 @@
<div>
<p class="text-muted mb-0">
<span class="badge bg-@typeColor bg-opacity-75 me-1">@typeLabel</span>
<span class="text-muted small">@Model.AccountSubType · @balanceLabel</span>
<span class="text-muted small">@Model.AccountSubType.ToDisplayName() · @balanceLabel</span>
</p>
</div>
<div class="ms-auto">
@@ -92,6 +92,21 @@
<p><strong>Contact Name:</strong> @(Model.ProspectContactName ?? "-")</p>
<p><strong>Email:</strong> @(Model.ProspectEmail ?? "-")</p>
<p><strong>Phone:</strong> @(Model.ProspectPhone ?? "-")</p>
<p>
<strong>SMS Consent:</strong>
@if (Model.ProspectSmsConsent)
{
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Consented</span>
@if (Model.ProspectSmsConsentedAt.HasValue)
{
<span class="text-muted small ms-1">on @Model.ProspectSmsConsentedAt.Value.ToLocalTime().ToString("MM/dd/yyyy")</span>
}
}
else
{
<span class="text-muted small"><i class="bi bi-dash-circle me-1"></i>Not recorded</span>
}
</p>
</div>
<div class="col-md-6">
<p><strong>Address:</strong> @(Model.ProspectAddress ?? "-")</p>
@@ -1528,6 +1543,8 @@
var detHasEmail = !string.IsNullOrWhiteSpace(Model.CustomerEmail);
var detHasMobile = !string.IsNullOrWhiteSpace(Model.CustomerMobilePhone);
var detHasSmsConsent = Model.CustomerNotifyBySms && detHasMobile;
var detProspectHasPhone = Model.IsProspect && !string.IsNullOrWhiteSpace(Model.ProspectPhone);
var detProspectSmsReady = detProspectHasPhone && Model.ProspectSmsConsent;
}
@if (Model.StatusCode != "APPROVED" && Model.StatusCode != "CONVERTED")
{
@@ -1549,19 +1566,36 @@
</button>
</form>
}
@if (detHasEmail)
@{
var detEmailOptedOut = detHasEmail && !Model.CustomerNotifyByEmail;
}
@if (detEmailOptedOut)
{
<button type="button" class="btn btn-outline-primary" disabled
title="@Model.CustomerName has email notifications turned off">
<i class="bi bi-envelope-arrow-up me-1"></i>Send Quote via Email
</button>
}
else if (detHasEmail || !string.IsNullOrWhiteSpace(Model.ProspectEmail))
{
<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)
else
{
<button type="button" class="btn btn-outline-primary"
data-bs-toggle="modal" data-bs-target="#quoteAdHocEmailModal">
<i class="bi bi-envelope-arrow-up me-1"></i>Send Quote via Email
</button>
}
@if (detHasMobile || detProspectSmsReady)
{
<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)
@if (!detHasMobile && !detHasEmail && !detProspectHasPhone && string.IsNullOrWhiteSpace(Model.ProspectEmail))
{
<div class="alert alert-warning alert-permanent py-1 px-2 small">
<i class="bi bi-exclamation-triangle me-1"></i>No email or mobile number on file — update the customer record to send this quote electronically.
@@ -1571,6 +1605,10 @@
{
<div class="text-muted small"><i class="bi bi-phone-slash me-1"></i>SMS consent required to send via text.</div>
}
@if (detProspectHasPhone && !Model.ProspectSmsConsent)
{
<div class="text-muted small"><i class="bi bi-phone-slash me-1"></i>SMS consent not recorded — edit the quote to enable SMS for this prospect.</div>
}
@if (!Model.ConvertedToJobId.HasValue)
{
<form asp-action="ConvertToJob" asp-route-id="@Model.Id" method="post" class="d-inline" id="createJobForm">
@@ -2103,6 +2141,30 @@
</style>
}
<!-- Ad-hoc Email Modal (no email on file) -->
<div class="modal fade" id="quoteAdHocEmailModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-envelope-arrow-up me-2"></i>Send Quote via Email</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="text-muted small mb-3">No email address is on file for this customer. Enter an address to send to:</p>
<label for="quoteAdHocEmailInput" class="form-label fw-medium">Send To</label>
<input type="email" id="quoteAdHocEmailInput" class="form-control" placeholder="recipient@example.com" />
<div id="quoteAdHocEmailError" class="text-danger small mt-1 d-none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="sendQuoteToAdHocEmail(@Model.Id)">
<i class="bi bi-send me-1"></i>Send
</button>
</div>
</div>
</div>
</div>
<!-- Send Quote via SMS Modal -->
<div class="modal fade" id="sendQuoteSmsModal" tabindex="-1" aria-labelledby="sendQuoteSmsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-sm">
@@ -2201,7 +2263,20 @@
@section Scripts {
<script src="~/js/customer-change.js" asp-append-version="true"></script>
<script>
function resendQuote(quoteId) {
function sendQuoteToAdHocEmail(quoteId) {
const email = (document.getElementById('quoteAdHocEmailInput').value ?? '').trim();
const errDiv = document.getElementById('quoteAdHocEmailError');
if (!email || !email.includes('@@')) {
errDiv.textContent = 'Please enter a valid email address.';
errDiv.classList.remove('d-none');
return;
}
errDiv.classList.add('d-none');
bootstrap.Modal.getInstance(document.getElementById('quoteAdHocEmailModal'))?.hide();
resendQuote(quoteId, email);
}
function resendQuote(quoteId, overrideEmail) {
// Reset modal state
document.getElementById('sendQuoteSending').classList.remove('d-none');
document.getElementById('sendQuoteResult').classList.add('d-none');
@@ -2212,8 +2287,10 @@
modal.show();
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
const url = '@Url.Action("ResendQuote", "Quotes")?id=' + quoteId
+ (overrideEmail ? '&overrideEmail=' + encodeURIComponent(overrideEmail) : '');
fetch('@Url.Action("ResendQuote", "Quotes")?id=' + quoteId, {
fetch(url, {
method: 'POST',
headers: { 'RequestVerificationToken': token, 'X-Requested-With': 'XMLHttpRequest' }
})