Compare commits

...

13 Commits

Author SHA1 Message Date
spouliot 90f333c8f3 Fix SMS Agreements version display and auto-remove stale templates
Fix Razor rendering of TermsVersion — property chains after a literal
character need @() parentheses or Razor misparses the expression.

Also adds cleanup to EnsureNotificationTemplatesSeededAsync to remove
stale template rows (no longer canonical, never customised) on next
settings visit, so retired types like JobReadyForPickup SMS disappear
automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 11:02:25 -04:00
spouliot 0b6a7a14c4 Add Quote Sent SMS template and fix consent confirmation wording
Adds a customizable QuoteSent SMS template to seed data and
DefaultTemplates so companies can edit the quote approval message
from Notification Templates. Wires NotifyQuoteSentSmsAsync to use
the template system instead of a hardcoded string. Updates
SmsConsentConfirmation wording to mention quote approvals alongside
job updates. Help docs and AI knowledge base updated to match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 21:19:43 -04:00
spouliot a9048dea2e Show email and SMS notification status on customer list and details
Added notification preference indicators to both views so staff can
see at a glance whether a customer has email/SMS enabled without
opening edit mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 20:27:28 -04:00
spouliot 3ff6a96bc8 Add SMS START/re-subscribe handling to Twilio webhook
Customers who replied STOP by mistake can now reply START, YES, or
UNSTOP to automatically re-enable their SMS opt-in — no staff action
needed. Adds SmsInboundStart notification type, HandleStartAsync in
WebhooksController, and updates AI knowledge base and help docs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 20:09:49 -04:00
spouliot 8148908a66 Merge branch 'dev' 2026-05-02 19:31:28 -04:00
spouliot c18b580ec9 Add SMS Agreements admin page and update help docs
- Add /SmsAgreements SuperAdmin page listing per-company SMS terms acceptance
  status, with stats cards, filter/search, and a full acceptance history modal
  (terms version, accepted by, timestamp, IP, user agent)
- Add SMS Agreements nav link under Tenants & Billing in the platform sidebar
- Update HelpKnowledgeBase and Help docs (Quotes, Settings) to document
  quote approval via SMS and the reuse of existing approval tokens

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 10:17:11 -04:00
spouliot a9a8ea41c6 Merge branch 'dev' 2026-04-30 08:23:40 -04:00
spouliot 167dc0c146 Merge dev into master 2026-04-29 13:37:26 -04:00
spouliot 3669fda852 Merge branch 'dev' 2026-04-29 09:23:26 -04:00
spouliot 296f85e33b Fix progress widget 'Set how you get paid' link pointing to non-existent #general tab
Payment terms lives in the App Defaults tab (#app-defaults), not #general.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 08:41:07 -04:00
spouliot 900a52f89d Merge branch 'dev' 2026-04-29 08:11:37 -04:00
spouliot 90a01571e3 Merge dev into master
- AI Catalog Price Check (Haiku model, rate limiting, progress bar, quarterly limit)
- Three-layer feature gating for AI Catalog Price Check (platform/plan/company)
- Passkey biometric login improvements (enrollment prompt, RPID fix, dismiss option)
- Company admin navigation consolidation (Subscription & Features button)
- Unit tests for 9 new services/controllers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 10:35:29 -04:00
spouliot 931d6d40da Merge dev into master for deployment 2026-04-24 21:29:52 -04:00
14 changed files with 577 additions and 16 deletions
@@ -168,6 +168,8 @@ public class CustomerListDto
public decimal CurrentBalance { get; set; }
public bool IsActive { get; set; }
public DateTime? LastContactDate { get; set; }
public bool NotifyByEmail { get; set; }
public bool NotifyBySms { get; set; }
}
public class AddCreditDto
@@ -18,5 +18,6 @@ public enum NotificationType
SubscriptionExpired = 11,
SmsInboundStop = 12,
SmsInboundHelp = 13,
AdminEmail = 14
AdminEmail = 14,
SmsInboundStart = 15
}
@@ -860,7 +860,7 @@ New accounts walk through an 18-step setup wizard to configure company informati
}
/// <summary>
/// Returns the 8 canonical default notification templates for a company.
/// Returns the canonical default notification templates for a company.
/// Called by both SeedData and CompanySettingsController for auto-seeding.
/// </summary>
public static List<NotificationTemplate> BuildDefaultNotificationTemplates(int companyId)
@@ -934,12 +934,23 @@ New accounts walk through an 18-step setup wizard to configure company informati
CreatedAt = DateTime.UtcNow
},
new NotificationTemplate
{
NotificationType = NotificationType.QuoteSent,
Channel = NotificationChannel.Sms,
DisplayName = "Quote Sent (SMS)",
Subject = null,
Body = "{{companyName}}: Quote {{quoteNumber}} for {{quoteTotal}} is ready for your review. Approve or decline: {{approvalUrl}} Reply STOP to opt out.",
IsActive = true,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
},
new NotificationTemplate
{
NotificationType = NotificationType.SmsConsentConfirmation,
Channel = NotificationChannel.Sms,
DisplayName = "SMS Enrollment Confirmation",
Subject = null,
Body = "{{companyName}}: You're now enrolled for SMS job & pickup updates. Reply STOP at any time to opt out. Msg & data rates may apply.",
Body = "{{companyName}}: You're now enrolled for SMS job updates and quote approvals. Reply STOP at any time to opt out. Msg & data rates may apply.",
IsActive = true,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
@@ -224,7 +224,17 @@ public class NotificationService : INotificationService
}
}
var message = $"{companyName}: Quote {quote.QuoteNumber} for {quote.Total:C} is ready for your review. Approve or decline: {approvalUrl} Reply STOP to opt out.";
var smsValues = new Dictionary<string, string>
{
["companyName"] = companyName,
["quoteNumber"] = quote.QuoteNumber ?? string.Empty,
["quoteTotal"] = quote.Total.ToString("C"),
["approvalUrl"] = approvalUrl
};
var message = await GetRenderedSmsAsync(
quote.CompanyId, NotificationType.QuoteSent, smsValues,
$"{companyName}: Quote {quote.QuoteNumber} for {quote.Total:C} is ready for your review. Approve or decline: {approvalUrl} Reply STOP to opt out.");
var (success, error) = await _smsService.SendSmsAsync(smsPhone, message);
await WriteLog(new NotificationLog
@@ -945,7 +955,7 @@ public class NotificationService : INotificationService
var smsMessage = await GetRenderedSmsAsync(
customer.CompanyId, NotificationType.SmsConsentConfirmation, values,
$"{companyName}: You're now enrolled for SMS job & pickup updates. Reply STOP at any time to opt out. Msg & data rates may apply.");
$"{companyName}: You're now enrolled for SMS job updates and quote approvals. Reply STOP at any time to opt out. Msg & data rates may apply.");
var (success, error) = await _smsService.SendSmsAsync(smsPhone, smsMessage);
@@ -1103,13 +1113,17 @@ public class NotificationService : INotificationService
"Job {{jobNumber}} Complete — {{companyName}}",
"<p>Dear {{customerName}},</p><p>Your job <strong>{{jobNumber}}</strong> is complete. Final price: <strong>{{finalPrice}}</strong>. It is now ready for pickup.</p><p>Thank you for choosing {{companyName}}.</p>"
),
[(NotificationType.QuoteSent, NotificationChannel.Sms)] = (
null,
"{{companyName}}: Quote {{quoteNumber}} for {{quoteTotal}} is ready for your review. Approve or decline: {{approvalUrl}} Reply STOP to opt out."
),
[(NotificationType.JobCompleted, NotificationChannel.Sms)] = (
null,
"{{companyName}}: Job {{jobNumber}} is done and ready for pickup! Reply STOP to opt out."
),
[(NotificationType.SmsConsentConfirmation, NotificationChannel.Sms)] = (
null,
"{{companyName}}: You're now enrolled for SMS job & pickup updates. Reply STOP at any time to opt out. Msg & data rates may apply."
"{{companyName}}: You're now enrolled for SMS job updates and quote approvals. Reply STOP at any time to opt out. Msg & data rates may apply."
),
[(NotificationType.InvoiceSent, NotificationChannel.Email)] = (
"Invoice {{invoiceNumber}} from {{companyName}}",
@@ -2557,24 +2557,37 @@ public class CompanySettingsController : Controller
/// company. Called on every visit to the Settings Index and NotificationTemplates pages so new
/// notification types added to <c>SeedData.BuildDefaultNotificationTemplates</c> are automatically
/// provisioned without requiring a migration or a manual "Seed Data" action by the platform admin.
/// Returns the count of newly added templates so the caller can decide whether to reload from the DB.
/// Also removes stale rows (no longer in the canonical list) that have never been customised
/// (UpdatedAt == null), so retired notification types disappear from the UI automatically.
/// Returns the count of changes so the caller can decide whether to reload from the DB.
/// </summary>
private async Task<int> EnsureNotificationTemplatesSeededAsync(
int companyId, List<NotificationTemplate> existing)
{
var allDefaults = SeedData.BuildDefaultNotificationTemplates(companyId);
var toAdd = allDefaults
.Where(d => !existing.Any(e =>
e.NotificationType == d.NotificationType && e.Channel == d.Channel))
.ToList();
// Remove rows that are no longer canonical and have never been customised.
var toRemove = existing
.Where(e => !allDefaults.Any(d =>
d.NotificationType == e.NotificationType && d.Channel == e.Channel)
&& e.UpdatedAt == null)
.ToList();
foreach (var t in toAdd)
await _unitOfWork.NotificationTemplates.AddAsync(t);
if (toAdd.Count > 0)
foreach (var t in toRemove)
await _unitOfWork.NotificationTemplates.DeleteAsync(t);
if (toAdd.Count > 0 || toRemove.Count > 0)
await _unitOfWork.CompleteAsync();
return toAdd.Count;
return toAdd.Count + toRemove.Count;
}
/// <summary>
@@ -0,0 +1,99 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// SuperAdmin view of per-company SMS terms agreement history.
/// Shows which companies have accepted the current SMS terms, who accepted them,
/// and the full acceptance log for each company.
/// </summary>
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class SmsAgreementsController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<SmsAgreementsController> _logger;
public SmsAgreementsController(IUnitOfWork unitOfWork, ILogger<SmsAgreementsController> logger)
{
_unitOfWork = unitOfWork;
_logger = logger;
}
/// <summary>
/// Lists every company with its current SMS agreement status and full acceptance history.
/// Uses IgnoreQueryFilters on both queries so deleted/inactive companies and all historical
/// agreement records are included in the audit view.
/// </summary>
public async Task<IActionResult> Index(string? search = null, string? filter = null)
{
var companies = await _unitOfWork.Companies.GetAllAsync(ignoreQueryFilters: true);
var allAgreements = await _unitOfWork.CompanySmsAgreements.GetAllAsync(ignoreQueryFilters: true);
var agreementsByCompany = allAgreements
.GroupBy(a => a.CompanyId)
.ToDictionary(g => g.Key, g => g.OrderByDescending(a => a.AgreedAt).ToList());
var rows = companies
.OrderBy(c => c.CompanyName)
.Select(c =>
{
var agreements = agreementsByCompany.TryGetValue(c.Id, out var list) ? list : [];
var current = agreements.FirstOrDefault(a => a.TermsVersion == AppConstants.SmsTermsVersion);
return new CompanySmsRow
{
CompanyId = c.Id,
CompanyName = c.CompanyName ?? "(unnamed)",
SmsEnabled = c.SmsEnabled,
SmsDisabledByAdmin = c.SmsDisabledByAdmin,
CurrentAgreement = current,
LatestAgreement = agreements.FirstOrDefault(),
AllAgreements = agreements,
IsDeleted = c.IsDeleted
};
})
.ToList();
// Filter
rows = filter switch
{
"accepted" => rows.Where(r => r.CurrentAgreement != null).ToList(),
"pending" => rows.Where(r => r.CurrentAgreement == null).ToList(),
"enabled" => rows.Where(r => r.SmsEnabled).ToList(),
"disabled" => rows.Where(r => r.SmsDisabledByAdmin).ToList(),
_ => rows
};
if (!string.IsNullOrWhiteSpace(search))
rows = rows.Where(r => r.CompanyName.Contains(search, StringComparison.OrdinalIgnoreCase)).ToList();
ViewBag.Search = search;
ViewBag.Filter = filter ?? "all";
ViewBag.CurrentTermsVersion = AppConstants.SmsTermsVersion;
// Stats (pre-filter totals)
var all = companies.ToList();
ViewBag.TotalCompanies = all.Count(c => !c.IsDeleted);
ViewBag.AcceptedCount = agreementsByCompany.Count(kvp =>
kvp.Value.Any(a => a.TermsVersion == AppConstants.SmsTermsVersion) &&
!all.FirstOrDefault(c => c.Id == kvp.Key)?.IsDeleted == true);
ViewBag.SmsEnabledCount = all.Count(c => !c.IsDeleted && c.SmsEnabled);
return View(rows);
}
}
/// <summary>View model for one company row on the SMS agreements page.</summary>
public class CompanySmsRow
{
public int CompanyId { get; set; }
public string CompanyName { get; set; } = string.Empty;
public bool SmsEnabled { get; set; }
public bool SmsDisabledByAdmin { get; set; }
public bool IsDeleted { get; set; }
public PowderCoating.Core.Entities.CompanySmsAgreement? CurrentAgreement { get; set; }
public PowderCoating.Core.Entities.CompanySmsAgreement? LatestAgreement { get; set; }
public List<PowderCoating.Core.Entities.CompanySmsAgreement> AllAgreements { get; set; } = [];
}
@@ -31,6 +31,12 @@ public class WebhooksController : ControllerBase
"STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"
};
// CTIA-standard opt-in keywords
private static readonly HashSet<string> StartKeywords = new(StringComparer.OrdinalIgnoreCase)
{
"START", "YES", "UNSTOP"
};
// CTIA-standard help keywords
private static readonly HashSet<string> HelpKeywords = new(StringComparer.OrdinalIgnoreCase)
{
@@ -77,6 +83,9 @@ public class WebhooksController : ControllerBase
if (StopKeywords.Contains(body))
return await HandleStopAsync(payload.From);
if (StartKeywords.Contains(body))
return await HandleStartAsync(payload.From);
if (HelpKeywords.Contains(body))
return await HandleHelpAsync(payload.From);
@@ -123,6 +132,44 @@ public class WebhooksController : ControllerBase
$"Reply START to re-subscribe.");
}
// ── START ─────────────────────────────────────────────────────────────────
/// <summary>
/// Processes a START keyword: re-enables SMS for the customer, clears SmsOptedOutAt,
/// logs to NotificationLog, and returns a TwiML confirmation message.
/// </summary>
private async Task<IActionResult> HandleStartAsync(string from)
{
var (customer, digits10) = await FindCustomerByPhoneAsync(from);
if (customer == null)
{
_logger.LogWarning("Twilio START from {From} — no matching customer found", from);
return TwimlMessage("You have been re-subscribed and will receive messages again.");
}
var companyName = await GetCompanyNameAsync(customer.CompanyId);
if (!customer.NotifyBySms)
{
customer.NotifyBySms = true;
customer.SmsOptedOutAt = null;
customer.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation("Customer {CustomerId} re-subscribed to SMS via START reply", customer.Id);
}
await WriteInboundLogAsync(
NotificationType.SmsInboundStart,
customer,
from,
"START");
return TwimlMessage(
$"{companyName}: You have been re-subscribed and will receive messages again. " +
$"Reply STOP to unsubscribe at any time.");
}
// ── HELP ──────────────────────────────────────────────────────────────────
/// <summary>
@@ -1062,6 +1062,7 @@ public static class HelpKnowledgeBase
- Quote approved/rejected by customer
- Job status changes
- Job completed (email + SMS)
- Quote sent (SMS approval link)
- Invoice sent
- Payment received
- Overdue payment reminders
@@ -1082,8 +1083,14 @@ public static class HelpKnowledgeBase
On each customer's record (Edit Customer), check the **SMS Opt-In** box and enter a **Mobile Phone** number. A customer will not receive SMS messages unless both boxes are set. You are responsible for obtaining the customer's verbal or written consent before enabling this.
**What events send an SMS:**
- Quote sent sends the customer a link to review and approve or decline their quote.
- Job completed notifies the customer their job is done and ready for pickup.
Both SMS templates are customizable at Company Settings Notification Templates. Companies can change the wording of both the quote SMS and the job-completed SMS to match their voice.
**Sending a quote approval link via SMS:**
On any quote's Details page, click **Send Quote via SMS**. The system texts the customer's mobile number a short message with the approval link. If no valid approval token exists yet, one is generated automatically. If an email was already sent for the same quote, the existing token is reused so both the email link and the SMS link remain valid simultaneously. The customer follows the link to the self-service approval portal and can approve or decline from their phone. Prospects (non-customers) receive the SMS at their ProspectPhone number without requiring an opt-in check; registered customers must have SMS Opt-In enabled.
**Compose-before-send (Admin/Manager):**
When a Company Admin or Manager marks a job complete, the system pre-fills an SMS draft based on your notification template and opens a compose modal before sending. You can personalize the message on the spot. The message must contain "STOP" opt-out language it is appended automatically if missing.
@@ -1093,8 +1100,8 @@ public static class HelpKnowledgeBase
**Send SMS button:**
On any completed job's Details page, Company Admins and Managers see a **Send SMS** button that opens the same compose modal, allowing you to send a follow-up message at any time.
**Customer opt-out:**
If a customer replies STOP to any message, they are automatically opted out and will not receive further SMS messages. They can reply START to re-subscribe.
**Customer opt-out and opt back in:**
If a customer replies STOP (or STOPALL, CANCEL, END, QUIT, UNSUBSCRIBE) to any message, they are automatically opted out and will not receive further SMS messages. If they change their mind, they can reply START, YES, or UNSTOP to re-subscribe automatically no action needed from your staff. You can also manually toggle SMS back on from their customer record.
### Platform Announcements
Occasional platform-wide announcements from the Powder Coating Logix team are delivered directly to your notification bell not as page banners. These may cover new features, scheduled maintenance, or policy updates. They appear as **Announcement** type items in the bell dropdown.
@@ -132,6 +132,35 @@
}
</p>
</div>
<div class="col-12">
<label class="text-muted small mb-1">Notifications</label>
<div class="d-flex gap-2">
@if (Model.NotifyByEmail)
{
<span class="badge bg-success bg-opacity-10 text-success border border-success border-opacity-25">
<i class="bi bi-envelope-fill me-1"></i>Email on
</span>
}
else
{
<span class="badge bg-secondary bg-opacity-10 text-secondary border border-secondary border-opacity-25">
<i class="bi bi-envelope-slash me-1"></i>Email off
</span>
}
@if (Model.NotifyBySms)
{
<span class="badge bg-success bg-opacity-10 text-success border border-success border-opacity-25">
<i class="bi bi-chat-fill me-1"></i>SMS on
</span>
}
else
{
<span class="badge bg-secondary bg-opacity-10 text-secondary border border-secondary border-opacity-25">
<i class="bi bi-chat-slash me-1"></i>SMS off
</span>
}
</div>
</div>
</div>
</div>
</div>
@@ -74,6 +74,7 @@
<th>Type</th>
<th sortable="CurrentBalance" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Balance</th>
<th sortable="IsActive" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Status</th>
<th>Notifications</th>
<th class="text-end pe-4">Actions</th>
</tr>
</thead>
@@ -152,6 +153,14 @@
<td>
@await Html.PartialAsync("_StatusChip", (Kind: StatusChipHelper.Active(customer.IsActive), Text: customer.IsActive ? "Active" : "Inactive"))
</td>
<td>
<span title="@(customer.NotifyByEmail ? "Email notifications on" : "Email notifications off")">
<i class="bi @(customer.NotifyByEmail ? "bi-envelope-fill text-success" : "bi-envelope-slash text-secondary opacity-50")"></i>
</span>
<span class="ms-2" title="@(customer.NotifyBySms ? "SMS notifications on" : "SMS notifications off")">
<i class="bi @(customer.NotifyBySms ? "bi-chat-fill text-success" : "bi-chat-slash text-secondary opacity-50")"></i>
</span>
</td>
<td class="text-end pe-4">
<div class="btn-group btn-group-sm">
<a asp-action="Details" asp-route-id="@customer.Id" class="btn btn-outline-primary" title="View Details">
+16 -4
View File
@@ -265,12 +265,13 @@
<i class="bi bi-send text-primary me-2"></i>Sending a Quote
</h2>
<p>
Once a quote is saved as a Draft and you are happy with the pricing and details, you can mark it
as sent to the customer.
Once a quote is saved as a Draft and you are happy with the pricing and details, you can send it
to the customer via email or SMS, or both.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2">Send via Email</h3>
<ol class="mb-3">
<li class="mb-2">Open the quote from the Quotes list and go to its Details page.</li>
<li class="mb-2">Click <strong>Send Quote</strong>. The status changes from Draft to Sent.</li>
<li class="mb-2">Click <strong>Send Quote via Email</strong>. The status changes from Draft to Sent and a PDF is emailed to the customer with an approval link.</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">
@@ -283,9 +284,20 @@
under their contact settings.
</div>
</div>
<h3 class="h6 fw-semibold mt-3 mb-2">Send via SMS</h3>
<p>
Click <strong>Send Quote via SMS</strong> on the Details page to text the customer a short message
containing their quote total and a link to the self-service approval portal. The customer can open the
link on their phone and approve or decline without logging in.
</p>
<ul class="mb-3">
<li class="mb-1">The customer must have <strong>SMS Opt-In</strong> enabled and a <strong>Mobile Phone</strong> number on their record.</li>
<li class="mb-1">If you already sent the quote via email, the same approval link is reused — both the email link and SMS link remain valid simultaneously.</li>
<li class="mb-1">For prospect quotes, the SMS goes to the <strong>Prospect Phone</strong> field on the quote.</li>
</ul>
<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.
you hear back from the customer verbally or by phone, without going through a formal email or SMS send.
Use the status buttons on the quote Details page to do this.
</p>
<div class="alert alert-permanent alert-secondary d-flex gap-2 mb-0" role="alert">
@@ -392,8 +392,18 @@
</ul>
<h4 class="h6 fw-semibold mt-3 mb-2" style="font-size:.85rem;">What events send an SMS</h4>
<ul class="mb-3">
<li class="mb-1">
<strong>Quote Sent</strong> — sends the customer a link to review and approve or decline their
quote directly from their phone. Click <strong>Send Quote via SMS</strong> on any quote's Details
page. If an email was already sent for the same quote, the existing approval link is reused so both
delivery methods work simultaneously.
</li>
<li class="mb-1"><strong>Job Completed</strong> — notifies the customer their job is done and ready for pickup.</li>
</ul>
<p class="small text-muted mb-3">
Both SMS message templates can be customised at
<strong>Company Settings &rsaquo; Notification Templates</strong>.
</p>
<h4 class="h6 fw-semibold mt-3 mb-2" style="font-size:.85rem;">Compose-before-send vs. auto-send</h4>
<p>
When a <strong>Company Admin or Manager</strong> marks a job complete, the system pre-fills a draft
@@ -410,7 +420,9 @@
<div>
Every outbound SMS automatically includes opt-out instructions ("Reply STOP to opt out"). If a
customer replies STOP, they are immediately opted out and will receive no further messages.
You can re-enable them on their customer record if they later ask to be re-subscribed.
If they change their mind, they can reply <strong>START</strong>, <strong>YES</strong>, or <strong>UNSTOP</strong>
to re-subscribe automatically — no action needed from your staff. You can also manually re-enable
SMS on their customer record.
</div>
</div>
</section>
@@ -1199,6 +1199,10 @@
<i class="bi bi-lightning-charge"></i>
<span>Stripe Events</span>
</a>
<a asp-controller="SmsAgreements" asp-action="Index" class="nav-link">
<i class="bi bi-file-earmark-check"></i>
<span>SMS Agreements</span>
</a>
<div class="nav-section-title">Content &amp; Communication</div>
<a asp-controller="Announcements" asp-action="Index" class="nav-link">
@@ -0,0 +1,301 @@
@model List<PowderCoating.Web.Controllers.CompanySmsRow>
@{
ViewData["Title"] = "SMS Agreements";
var currentVersion = ViewBag.CurrentTermsVersion as string ?? "1.0";
var filter = ViewBag.Filter as string ?? "all";
var search = ViewBag.Search as string ?? "";
}
<div class="container-fluid py-4">
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h1 class="h3 mb-0"><i class="bi bi-file-earmark-check me-2 text-primary"></i>SMS Agreements</h1>
<p class="text-muted mb-0 small">Per-company SMS terms acceptance log &mdash; current terms version: <strong>v@currentVersion</strong></p>
</div>
</div>
<!-- Stats -->
<div class="row g-3 mb-4">
<div class="col-sm-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body d-flex align-items-center gap-3">
<div class="rounded-circle bg-primary bg-opacity-10 d-flex align-items-center justify-content-center" style="width:48px;height:48px;flex-shrink:0">
<i class="bi bi-building text-primary fs-5"></i>
</div>
<div>
<div class="fs-4 fw-bold lh-1">@ViewBag.TotalCompanies</div>
<div class="text-muted small">Active Companies</div>
</div>
</div>
</div>
</div>
<div class="col-sm-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body d-flex align-items-center gap-3">
<div class="rounded-circle bg-success bg-opacity-10 d-flex align-items-center justify-content-center" style="width:48px;height:48px;flex-shrink:0">
<i class="bi bi-check-circle text-success fs-5"></i>
</div>
<div>
<div class="fs-4 fw-bold lh-1">@ViewBag.AcceptedCount</div>
<div class="text-muted small">Accepted Current Terms</div>
</div>
</div>
</div>
</div>
<div class="col-sm-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body d-flex align-items-center gap-3">
<div class="rounded-circle bg-info bg-opacity-10 d-flex align-items-center justify-content-center" style="width:48px;height:48px;flex-shrink:0">
<i class="bi bi-chat-dots text-info fs-5"></i>
</div>
<div>
<div class="fs-4 fw-bold lh-1">@ViewBag.SmsEnabledCount</div>
<div class="text-muted small">SMS Currently Enabled</div>
</div>
</div>
</div>
</div>
</div>
<!-- Filters + Search -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<div class="d-flex flex-wrap gap-2 align-items-center justify-content-between">
<div class="d-flex flex-wrap gap-2">
<a asp-action="Index" asp-route-search="@search"
class="btn btn-sm @(filter == "all" ? "btn-primary" : "btn-outline-secondary")">All</a>
<a asp-action="Index" asp-route-filter="accepted" asp-route-search="@search"
class="btn btn-sm @(filter == "accepted" ? "btn-success" : "btn-outline-success")">
<i class="bi bi-check-circle me-1"></i>Accepted Current Terms
</a>
<a asp-action="Index" asp-route-filter="pending" asp-route-search="@search"
class="btn btn-sm @(filter == "pending" ? "btn-warning" : "btn-outline-warning")">
<i class="bi bi-clock me-1"></i>Not Accepted
</a>
<a asp-action="Index" asp-route-filter="enabled" asp-route-search="@search"
class="btn btn-sm @(filter == "enabled" ? "btn-info" : "btn-outline-info")">
<i class="bi bi-chat-dots me-1"></i>SMS Enabled
</a>
<a asp-action="Index" asp-route-filter="disabled" asp-route-search="@search"
class="btn btn-sm @(filter == "disabled" ? "btn-danger" : "btn-outline-danger")">
<i class="bi bi-slash-circle me-1"></i>Admin-Disabled
</a>
</div>
<form method="get" class="d-flex gap-2" style="min-width:240px;">
<input type="hidden" name="filter" value="@filter" />
<input type="text" name="search" value="@search" class="form-control form-control-sm"
placeholder="Search company&hellip;" />
<button type="submit" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-search"></i>
</button>
@if (!string.IsNullOrWhiteSpace(search))
{
<a asp-action="Index" asp-route-filter="@filter" class="btn btn-sm btn-outline-danger">
<i class="bi bi-x"></i>
</a>
}
</form>
</div>
</div>
</div>
<!-- Table -->
<div class="card border-0 shadow-sm">
<div class="card-body p-0">
@if (!Model.Any())
{
<div class="text-center text-muted py-5">
<i class="bi bi-file-earmark-x fs-1 d-block mb-2 opacity-25"></i>
No companies match this filter.
</div>
}
else
{
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Company</th>
<th>SMS Status</th>
<th>Terms Accepted</th>
<th>Accepted By</th>
<th>Accepted At</th>
<th>IP Address</th>
<th class="text-center">History</th>
</tr>
</thead>
<tbody>
@foreach (var row in Model)
{
<tr class="@(row.IsDeleted ? "text-muted" : "")">
<td>
<div class="fw-medium">
@row.CompanyName
@if (row.IsDeleted)
{
<span class="badge bg-secondary ms-1">Deleted</span>
}
</div>
</td>
<td>
@if (row.SmsDisabledByAdmin)
{
<span class="badge bg-danger"><i class="bi bi-slash-circle me-1"></i>Admin-Disabled</span>
}
else if (row.SmsEnabled)
{
<span class="badge bg-success"><i class="bi bi-chat-dots me-1"></i>Enabled</span>
}
else
{
<span class="badge bg-secondary">Off</span>
}
</td>
<td>
@if (row.CurrentAgreement != null)
{
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>v@(row.CurrentAgreement.TermsVersion)</span>
}
else if (row.LatestAgreement != null)
{
<span class="badge bg-warning text-dark" title="Accepted v@(row.LatestAgreement.TermsVersion) — current is v@currentVersion">
<i class="bi bi-exclamation-triangle me-1"></i>Stale (v@(row.LatestAgreement.TermsVersion))
</span>
}
else
{
<span class="badge bg-light text-muted border">Never</span>
}
</td>
<td>
@if (row.CurrentAgreement != null)
{
<span>@row.CurrentAgreement.AgreedByUserName</span>
}
else if (row.LatestAgreement != null)
{
<span class="text-muted">@row.LatestAgreement.AgreedByUserName</span>
}
else
{
<span class="text-muted">—</span>
}
</td>
<td>
@{
var displayAgreement = row.CurrentAgreement ?? row.LatestAgreement;
}
@if (displayAgreement != null)
{
<span class="@(row.CurrentAgreement == null ? "text-muted" : "")">
@displayAgreement.AgreedAt.ToString("MMM d, yyyy 'at' h:mm tt") UTC
</span>
}
else
{
<span class="text-muted">—</span>
}
</td>
<td>
@if (displayAgreement?.IpAddress != null)
{
<code class="small @(row.CurrentAgreement == null ? "text-muted" : "")">@displayAgreement.IpAddress</code>
}
else
{
<span class="text-muted">—</span>
}
</td>
<td class="text-center">
@if (row.AllAgreements.Count > 0)
{
<button type="button"
class="btn btn-sm btn-outline-secondary"
data-bs-toggle="modal"
data-bs-target="#historyModal"
data-company="@row.CompanyName"
data-history="@System.Text.Json.JsonSerializer.Serialize(row.AllAgreements.Select(a => new {
a.TermsVersion,
a.AgreedByUserName,
a.AgreedByUserId,
AgreedAt = a.AgreedAt.ToString("MMM d, yyyy 'at' h:mm tt") + " UTC",
IpAddress = a.IpAddress ?? "—",
UserAgent = a.UserAgent ?? "—"
}))">
@row.AllAgreements.Count <i class="bi bi-clock-history ms-1"></i>
</button>
}
else
{
<span class="text-muted small">—</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
@if (Model.Any())
{
<div class="card-footer text-muted small">
Showing @Model.Count @(Model.Count == 1 ? "company" : "companies")
</div>
}
</div>
</div>
<!-- History Modal -->
<div class="modal fade" id="historyModal" tabindex="-1" aria-labelledby="historyModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="historyModalLabel">
<i class="bi bi-clock-history me-2"></i>Agreement History — <span id="historyCompanyName"></span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0">
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead class="table-light">
<tr>
<th>Terms Version</th>
<th>Accepted By</th>
<th>Accepted At</th>
<th>IP Address</th>
<th>User Agent</th>
</tr>
</thead>
<tbody id="historyTableBody"></tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
@section Scripts {
<script>
document.getElementById('historyModal').addEventListener('show.bs.modal', function (e) {
const btn = e.relatedTarget;
document.getElementById('historyCompanyName').textContent = btn.dataset.company;
const rows = JSON.parse(btn.dataset.history);
const tbody = document.getElementById('historyTableBody');
tbody.innerHTML = rows.map(r => `
<tr>
<td><span class="badge bg-primary">v${r.termsVersion}</span></td>
<td>${r.agreedByUserName}</td>
<td>${r.agreedAt}</td>
<td><code class="small">${r.ipAddress}</code></td>
<td><small class="text-muted text-truncate d-block" style="max-width:260px;" title="${r.userAgent}">${r.userAgent}</small></td>
</tr>`).join('');
});
</script>
}