Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 90f333c8f3 | |||
| 0b6a7a14c4 | |||
| a9048dea2e | |||
| 3ff6a96bc8 | |||
| 8148908a66 | |||
| c18b580ec9 | |||
| a9a8ea41c6 | |||
| 167dc0c146 | |||
| 3669fda852 | |||
| 296f85e33b | |||
| 900a52f89d | |||
| 90a01571e3 | |||
| 931d6d40da |
@@ -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">
|
||||
|
||||
@@ -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 › 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 & 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 — 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…" />
|
||||
<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>
|
||||
}
|
||||
Reference in New Issue
Block a user