diff --git a/src/PowderCoating.Web/Controllers/SmsAgreementsController.cs b/src/PowderCoating.Web/Controllers/SmsAgreementsController.cs new file mode 100644 index 0000000..05fdc7f --- /dev/null +++ b/src/PowderCoating.Web/Controllers/SmsAgreementsController.cs @@ -0,0 +1,99 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using PowderCoating.Core.Interfaces; +using PowderCoating.Shared.Constants; + +namespace PowderCoating.Web.Controllers; + +/// +/// 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. +/// +[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)] +public class SmsAgreementsController : Controller +{ + private readonly IUnitOfWork _unitOfWork; + private readonly ILogger _logger; + + public SmsAgreementsController(IUnitOfWork unitOfWork, ILogger logger) + { + _unitOfWork = unitOfWork; + _logger = logger; + } + + /// + /// 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. + /// + public async Task 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); + } +} + +/// View model for one company row on the SMS agreements page. +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 AllAgreements { get; set; } = []; +} diff --git a/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs b/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs index b5e4ff7..14360e5 100644 --- a/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs +++ b/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs @@ -1083,6 +1083,10 @@ public static class HelpKnowledgeBase **What events send an SMS:** - Job completed — notifies the customer their job is done and ready for pickup. + - Quote approval request — sends the customer a link to review and approve or decline their quote. + + **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. diff --git a/src/PowderCoating.Web/Views/Help/Quotes.cshtml b/src/PowderCoating.Web/Views/Help/Quotes.cshtml index 3f69008..75cd5d6 100644 --- a/src/PowderCoating.Web/Views/Help/Quotes.cshtml +++ b/src/PowderCoating.Web/Views/Help/Quotes.cshtml @@ -265,12 +265,13 @@ Sending a Quote

- 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.

+

Send via Email

  1. Open the quote from the Quotes list and go to its Details page.
  2. -
  3. Click Send Quote. The status changes from Draft to Sent.
  4. +
  5. Click Send Quote via Email. The status changes from Draft to Sent and a PDF is emailed to the customer with an approval link.
  6. If email notifications are configured for your company, the customer will automatically receive an email with the quote details.
+

Send via SMS

+

+ Click Send Quote via SMS 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. +

+
    +
  • The customer must have SMS Opt-In enabled and a Mobile Phone number on their record.
  • +
  • If you already sent the quote via email, the same approval link is reused — both the email link and SMS link remain valid simultaneously.
  • +
  • For prospect quotes, the SMS goes to the Prospect Phone field on the quote.
  • +

You can also manually mark a quote as Approved or Rejected 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.