Merge branch 'dev'

This commit is contained in:
2026-05-02 19:31:28 -04:00
46 changed files with 21239 additions and 579 deletions
@@ -165,6 +165,9 @@ public class UpdateCompanyDto
public bool? OnlinePaymentsOverride { get; set; }
public bool? AccountingOverride { get; set; }
/// <summary>When true, SuperAdmin has force-disabled SMS for this company regardless of plan or company settings.</summary>
public bool SmsDisabledByAdmin { get; set; }
public string? TimeZone { get; set; }
}
@@ -35,6 +35,26 @@ namespace PowderCoating.Application.DTOs.Company
public decimal OnlinePaymentSurchargeValue { get; set; }
public bool OnlineSurchargeAcknowledged { get; set; }
public bool AllowOnlinePayments { get; set; }
// SMS gating
public bool AllowSms { get; set; }
public bool SmsEnabled { get; set; }
public bool SmsDisabledByAdmin { get; set; }
/// <summary>True when the company has an accepted agreement for the current SmsTermsVersion.</summary>
public bool HasCurrentSmsAgreement { get; set; }
public string SmsTermsVersion { get; set; } = string.Empty;
}
/// <summary>
/// DTO for the company admin SMS opt-in/out toggle.
/// When enabling for the first time (or after a terms version change), AgreedToTerms must
/// be true and TermsVersion must match <c>AppConstants.SmsTermsVersion</c>.
/// </summary>
public class UpdateSmsPreferencesDto
{
public bool SmsEnabled { get; set; }
public bool AgreedToTerms { get; set; }
public string? TermsVersion { get; set; }
}
/// <summary>
@@ -52,6 +52,10 @@ public class JobDto
public bool RequiresCustomerApproval { get; set; }
public bool IsCustomerApproved { get; set; }
// Customer SMS opt-in — used for SMS compose modal on job details
public bool CustomerNotifyBySms { get; set; }
public string? CustomerMobilePhone { get; set; }
// Job Completion Details
public decimal? ActualTimeSpentHours { get; set; }
@@ -380,6 +384,13 @@ public class CompleteJobDto
public bool SendEmailToCustomer { get; set; } = false;
}
// DTO for the Admin/Manager compose-before-send SMS endpoint
public class SendJobSmsRequest
{
public int JobId { get; set; }
public string Message { get; set; } = string.Empty;
}
// DTO for tracking actual powder usage per coat
public class JobItemCoatUsageDto
{
@@ -25,6 +25,7 @@ public class SubscriptionPlanConfigDto
public bool AllowAiPhotoQuotes { get; set; }
public bool AllowAiInventoryAssist { get; set; }
public bool AllowAiCatalogPriceCheck { get; set; }
public bool AllowSms { get; set; }
public bool IsActive { get; set; }
public int SortOrder { get; set; }
}
@@ -72,6 +73,7 @@ public class UpdateSubscriptionPlanConfigDto
public bool AllowAiPhotoQuotes { get; set; }
public bool AllowAiInventoryAssist { get; set; }
public bool AllowAiCatalogPriceCheck { get; set; }
public bool AllowSms { get; set; }
public bool IsActive { get; set; }
}
@@ -11,6 +11,13 @@ public interface INotificationService
/// </summary>
Task NotifyQuoteSentAsync(Quote quote, byte[]? pdfAttachment = null, string? pdfFilename = null);
/// <summary>
/// Sends the quote approval link to the customer via SMS.
/// Handles both registered customers (respects NotifyBySms) and prospects (ProspectPhone).
/// Returns (success, errorMessage) so the caller can surface the result to the user.
/// </summary>
Task<(bool Success, string? Error)> NotifyQuoteSentSmsAsync(Quote quote);
/// <summary>
/// Notify when a quote is approved by a customer.
/// </summary>
@@ -23,8 +30,23 @@ public interface INotificationService
/// <summary>
/// Notify customer when a job is completed and ready for pickup.
/// When <paramref name="suppressSms"/> is true the SMS is skipped so an admin can review
/// the message via <see cref="RenderJobCompletedSmsAsync"/> before sending manually.
/// </summary>
Task NotifyJobCompletedAsync(Job job);
Task NotifyJobCompletedAsync(Job job, bool suppressSms = false);
/// <summary>
/// Renders the job-completed SMS text for admin preview without sending it.
/// Returns null when SMS is not allowed for the company or the customer has not opted in.
/// </summary>
Task<string?> RenderJobCompletedSmsAsync(Job job);
/// <summary>
/// Sends a manually-composed SMS for a job (Admin/Manager compose-before-send path).
/// Appends "Reply STOP to opt out." if not already present, sends, and writes a NotificationLog row.
/// Returns (success, errorMessage).
/// </summary>
Task<(bool Success, string? Error)> SendJobSmsAsync(Job job, string message);
/// <summary>
/// Sends a welcome/confirmation SMS after staff records verbal SMS consent.
@@ -57,7 +57,13 @@ public class JobProfile : Profile
.ForMember(dest => dest.OriginalJobNumber,
opt => opt.MapFrom(src => src.OriginalJob != null ? src.OriginalJob.JobNumber : null))
.ForMember(dest => dest.IntakeCheckedByName,
opt => opt.MapFrom(src => src.IntakeCheckedBy != null ? src.IntakeCheckedBy.FullName : null));
opt => opt.MapFrom(src => src.IntakeCheckedBy != null ? src.IntakeCheckedBy.FullName : null))
.ForMember(dest => dest.CustomerNotifyBySms,
opt => opt.MapFrom(src => src.Customer != null && src.Customer.NotifyBySms))
.ForMember(dest => dest.CustomerMobilePhone,
opt => opt.MapFrom(src => src.Customer != null
? (src.Customer.MobilePhone ?? src.Customer.Phone)
: null));
// JobTimeEntry → JobTimeEntryDto
CreateMap<JobTimeEntry, JobTimeEntryDto>()
@@ -88,6 +88,19 @@ public class Company : BaseEntity
/// </summary>
public bool? AccountingOverride { get; set; }
/// <summary>
/// Company admin opt-in for SMS notifications. Defaults to false — company admin must
/// explicitly accept the SMS terms of service before enabling. Has no effect if the plan
/// does not allow SMS or if SmsDisabledByAdmin is true.
/// </summary>
public bool SmsEnabled { get; set; } = false;
/// <summary>
/// SuperAdmin force-disable for this company's SMS. When true, no SMS is sent regardless
/// of plan or company settings. Use when a company is abusing SMS or requests a full opt-out.
/// </summary>
public bool SmsDisabledByAdmin { get; set; } = false;
// Email marketing opt-out (CAN-SPAM compliance for platform broadcast emails)
public bool MarketingEmailOptOut { get; set; } = false;
public string MarketingUnsubscribeToken { get; set; } = Guid.NewGuid().ToString("N");
@@ -0,0 +1,33 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Immutable audit record of a company admin accepting the SMS terms of service.
/// One record is written each time a user accepts (including re-accepts after a terms update).
/// The most recent record whose <see cref="TermsVersion"/> matches
/// <c>AppConstants.SmsTermsVersion</c> is the authoritative acceptance for that company.
/// Never soft-deleted — this is a legal audit trail.
/// </summary>
public class CompanySmsAgreement : BaseEntity
{
/// <summary>The Identity user ID of the admin who clicked "I Agree".</summary>
public string AgreedByUserId { get; set; } = string.Empty;
/// <summary>Display name snapshot of the user at the time of agreement (for audit readability after user changes).</summary>
public string AgreedByUserName { get; set; } = string.Empty;
/// <summary>UTC timestamp of acceptance.</summary>
public DateTime AgreedAt { get; set; }
/// <summary>Client IP address at the time of acceptance. Stored for legal/fraud purposes.</summary>
public string? IpAddress { get; set; }
/// <summary>HTTP User-Agent header at the time of acceptance.</summary>
public string? UserAgent { get; set; }
/// <summary>
/// The version of the SMS terms that was accepted (matches <c>AppConstants.SmsTermsVersion</c>
/// at the moment of acceptance). When the platform bumps this version, existing records become
/// stale and the company must re-accept.
/// </summary>
public string TermsVersion { get; set; } = string.Empty;
}
@@ -49,6 +49,9 @@ public class SubscriptionPlanConfig : BaseEntity
/// <summary>When true, companies on this plan can run the AI Catalog Price Check (Enterprise only).</summary>
public bool AllowAiCatalogPriceCheck { get; set; } = false;
/// <summary>When true, companies on this plan can send SMS notifications to customers (subject to platform kill-switch and per-company opt-in).</summary>
public bool AllowSms { get; set; } = false;
public bool IsActive { get; set; } = true;
public int SortOrder { get; set; }
}
@@ -9,6 +9,7 @@ public interface IUnitOfWork : IDisposable
IRepository<Company> Companies { get; }
IRepository<CompanyOperatingCosts> CompanyOperatingCosts { get; }
IRepository<CompanyPreferences> CompanyPreferences { get; }
IRepository<CompanySmsAgreement> CompanySmsAgreements { get; }
// AI Predictions
IRepository<AiItemPrediction> AiItemPredictions { get; }
@@ -1,30 +1,127 @@
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Interfaces.Services;
/// <summary>
/// Result record carrying all pre-fetched entity lists and aggregates needed to render the operator
/// dashboard index view. Raw entities are returned so the controller can apply in-memory
/// filtering, grouping, and DTO projection without additional round-trips.
/// Result record carrying the pre-sliced entity lists and aggregates needed to render the
/// operator dashboard index view. The read service does the heavy SQL filtering so the
/// controller can focus on lightweight DTO projection and view assembly.
/// </summary>
public record DashboardIndexData(
List<Job> ActiveJobs,
decimal MonthlyRevenue,
int ActiveJobsCount,
int TodaysJobsCount,
List<Job> TodaysJobs,
int OverdueJobsCount,
List<Job> OverdueJobs,
List<Job> InProgressJobs,
int TodaysAppointmentsCount,
List<Appointment> TodaysAppointments,
int LowStockCount,
List<InventoryItem> LowStockItems,
int PendingMaintenanceCount,
List<MaintenanceRecord> UpcomingMaintenance,
int PendingQuotesCount,
decimal PendingQuoteValue,
List<Quote> PendingQuotes,
List<Invoice> OpenInvoices,
List<Quote> ExpiringQuotes,
int ActiveCustomersCount,
decimal MonthlyRevenue,
decimal OutstandingAr,
decimal InvoicedThisMonth,
decimal CollectedThisMonth,
int OverdueInvoicesCount,
decimal OverdueInvoicesAmount,
DashboardArAgingData ArAging,
List<Invoice> OverdueInvoices,
List<Payment> RecentPayments,
List<Quote> RecentQuotes,
List<Job> RecentJobs,
List<Job> JobsNeedingPowder,
List<Job> JobsWithOrderedPowder,
List<Equipment> EquipmentAlerts,
List<DashboardPowderOrderLineData> PowderOrdersNeeded,
List<DashboardPowderOrderLineData> PowderOrdersPlaced,
int BillsDueCount,
decimal BillsDueAmount,
List<Bill> BillsDue,
string? TipOfTheDay
);
/// <summary>
/// AR aging bucket totals used by the dashboard receivables summary.
/// </summary>
public record DashboardArAgingData(
decimal Current,
decimal Days1To30,
decimal Days31To60,
decimal Days61To90,
decimal DaysOver90
);
/// <summary>
/// Flattened powder-order line data so the controller does not need to materialize full job/item/coat graphs.
/// </summary>
public record DashboardPowderOrderLineData(
int CoatId,
int JobId,
string JobNumber,
string CustomerName,
string CoatName,
string? ColorName,
string? ColorCode,
string? Finish,
string? SKU,
decimal LbsToOrder,
decimal? CostPerLb,
DateTime? OrderedAt,
bool HasInventoryItem,
int? VendorId,
string? VendorName,
string? VendorPhone,
string? VendorEmail
);
/// <summary>
/// Aggregated data for the SuperAdmin dashboard.
/// </summary>
public record SuperAdminDashboardData(
int TotalCompanies,
int ActiveCompanies,
int InactiveCompanies,
int TotalUsers,
int ActiveSubscriptions,
int GracePeriodCount,
int ExpiredCount,
Dictionary<int, DashboardPlanDistributionData> PlanDistribution,
List<SuperAdminCompanyAlertData> CompanyAlerts,
List<SuperAdminRecentCompanyData> RecentCompanies
);
public record DashboardPlanDistributionData(
string DisplayName,
int Count
);
public record SuperAdminCompanyAlertData(
int Id,
string CompanyName,
int Plan,
string PlanDisplayName,
SubscriptionStatus Status,
DateTime? SubscriptionEndDate,
int DaysOverdue,
bool IsActive
);
public record SuperAdminRecentCompanyData(
int Id,
string CompanyName,
int Plan,
string PlanDisplayName,
SubscriptionStatus Status,
bool IsActive,
DateTime CreatedAt
);
/// <summary>
/// Read-only service for the dashboard. All methods execute complex queries that require
/// ThenInclude chains or navigation-property predicates beyond what the generic
@@ -37,6 +134,9 @@ public interface IDashboardReadService
/// <param name="today">The local date used for date-range predicates (today, start-of-month, etc.).</param>
Task<DashboardIndexData> GetIndexDataAsync(DateTime today);
/// <summary>Fetches all data needed to render the SuperAdmin dashboard.</summary>
Task<SuperAdminDashboardData> GetSuperAdminDashboardDataAsync(DateTime today);
/// <summary>Returns the total count of tenant users (CompanyId > 0) for the SuperAdmin dashboard.</summary>
Task<int> GetTotalUserCountAsync();
}
@@ -143,6 +143,9 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
/// <summary>Tenant company records. Soft-delete only filter applies (no CompanyId filter — SuperAdmin manages all companies).</summary>
public DbSet<Company> Companies { get; set; }
/// <summary>Immutable audit trail of SMS terms-of-service acceptances per company. Tenant-filtered by CompanyId; never soft-deleted.</summary>
public DbSet<CompanySmsAgreement> CompanySmsAgreements { get; set; }
/// <summary>AI quote-item predictions; tenant-filtered. Both <c>QuoteItem</c> and <c>JobItem</c> share a single prediction record via nullable FK (no duplication on quote→job conversion).</summary>
public DbSet<AiItemPrediction> AiItemPredictions { get; set; }
@@ -912,17 +912,6 @@ New accounts walk through an 18-step setup wizard to configure company informati
CreatedAt = DateTime.UtcNow
},
new NotificationTemplate
{
NotificationType = NotificationType.JobReadyForPickup,
Channel = NotificationChannel.Sms,
DisplayName = "Job Ready for Pickup (SMS)",
Subject = null,
Body = "{{companyName}}: Job {{jobNumber}} is ready for pickup! Reply STOP to opt out.",
IsActive = true,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
},
new NotificationTemplate
{
NotificationType = NotificationType.JobCompleted,
Channel = NotificationChannel.Email,
@@ -1204,6 +1193,17 @@ New accounts walk through an 18-step setup wizard to configure company informati
await context.SaveChangesAsync();
}
// Enable AllowSms for Pro and Enterprise plans if not already set
var smsPlansToFix = await context.SubscriptionPlanConfigs.IgnoreQueryFilters()
.Where(c => (c.Plan == 1 || c.Plan == 2) && !c.AllowSms)
.ToListAsync();
if (smsPlansToFix.Count > 0)
{
foreach (var row in smsPlansToFix)
row.AllowSms = true;
await context.SaveChangesAsync();
}
// Only seed if table is empty
if (await context.SubscriptionPlanConfigs.IgnoreQueryFilters().AnyAsync())
return;
@@ -1256,6 +1256,7 @@ New accounts walk through an 18-step setup wizard to configure company informati
MaxCatalogItems = 500,
MonthlyPrice = 79m,
AnnualPrice = 790m,
AllowSms = true,
IsActive = true,
SortOrder = 3,
CompanyId = 0,
@@ -1273,6 +1274,7 @@ New accounts walk through an 18-step setup wizard to configure company informati
MaxCatalogItems = -1,
MonthlyPrice = 199m,
AnnualPrice = 1990m,
AllowSms = true,
IsActive = true,
SortOrder = 4,
CompanyId = 0,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,94 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddSmsGating : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AllowSms",
table: "SubscriptionPlanConfigs",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "SmsDisabledByAdmin",
table: "Companies",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "SmsEnabled",
table: "Companies",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 2, 0, 8, 58, 880, DateTimeKind.Utc).AddTicks(523));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 2, 0, 8, 58, 880, DateTimeKind.Utc).AddTicks(529));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 2, 0, 8, 58, 880, DateTimeKind.Utc).AddTicks(531));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AllowSms",
table: "SubscriptionPlanConfigs");
migrationBuilder.DropColumn(
name: "SmsDisabledByAdmin",
table: "Companies");
migrationBuilder.DropColumn(
name: "SmsEnabled",
table: "Companies");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 4, 29, 22, 12, 13, 993, DateTimeKind.Utc).AddTicks(9171));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 4, 29, 22, 12, 13, 993, DateTimeKind.Utc).AddTicks(9177));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 4, 29, 22, 12, 13, 993, DateTimeKind.Utc).AddTicks(9179));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,90 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddCompanySmsAgreement : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "CompanySmsAgreements",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
AgreedByUserId = table.Column<string>(type: "nvarchar(max)", nullable: false),
AgreedByUserName = table.Column<string>(type: "nvarchar(max)", nullable: false),
AgreedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
IpAddress = table.Column<string>(type: "nvarchar(max)", nullable: true),
UserAgent = table.Column<string>(type: "nvarchar(max)", nullable: true),
TermsVersion = table.Column<string>(type: "nvarchar(max)", nullable: false),
CompanyId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_CompanySmsAgreements", x => x.Id);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4933));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4939));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4941));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CompanySmsAgreements");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 2, 0, 8, 58, 880, DateTimeKind.Utc).AddTicks(523));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 2, 0, 8, 58, 880, DateTimeKind.Utc).AddTicks(529));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 2, 0, 8, 58, 880, DateTimeKind.Utc).AddTicks(531));
}
}
}
@@ -1642,6 +1642,12 @@ namespace PowderCoating.Infrastructure.Migrations
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<bool>("SmsDisabledByAdmin")
.HasColumnType("bit");
b.Property<bool>("SmsEnabled")
.HasColumnType("bit");
b.Property<string>("State")
.HasColumnType("nvarchar(max)");
@@ -2116,6 +2122,64 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("CompanyPreferences");
});
modelBuilder.Entity("PowderCoating.Core.Entities.CompanySmsAgreement", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("AgreedAt")
.HasColumnType("datetime2");
b.Property<string>("AgreedByUserId")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("AgreedByUserName")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("IpAddress")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("TermsVersion")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("UserAgent")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("CompanySmsAgreements");
});
modelBuilder.Entity("PowderCoating.Core.Entities.ContactSubmission", b =>
{
b.Property<int>("Id")
@@ -5866,7 +5930,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 4, 29, 22, 12, 13, 993, DateTimeKind.Utc).AddTicks(9171),
CreatedAt = new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4933),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -5877,7 +5941,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 4, 29, 22, 12, 13, 993, DateTimeKind.Utc).AddTicks(9177),
CreatedAt = new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4939),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -5888,7 +5952,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 4, 29, 22, 12, 13, 993, DateTimeKind.Utc).AddTicks(9179),
CreatedAt = new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4941),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -7228,6 +7292,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("AllowOnlinePayments")
.HasColumnType("bit");
b.Property<bool>("AllowSms")
.HasColumnType("bit");
b.Property<decimal>("AnnualPrice")
.HasColumnType("decimal(18,2)");
@@ -36,6 +36,7 @@ public class UnitOfWork : IUnitOfWork
private IRepository<Company>? _companies;
private IRepository<CompanyOperatingCosts>? _companyOperatingCosts;
private IRepository<CompanyPreferences>? _companyPreferences;
private IRepository<CompanySmsAgreement>? _companySmsAgreements;
// AI Predictions
private IRepository<AiItemPrediction>? _aiItemPredictions;
@@ -170,6 +171,10 @@ public class UnitOfWork : IUnitOfWork
public IRepository<CompanyPreferences> CompanyPreferences =>
_companyPreferences ??= new Repository<CompanyPreferences>(_context);
/// <summary>Repository for <see cref="CompanySmsAgreement"/> audit records. Tenant-filtered; never soft-deleted — legal audit trail.</summary>
public IRepository<CompanySmsAgreement> CompanySmsAgreements =>
_companySmsAgreements ??= new Repository<CompanySmsAgreement>(_context);
// AI Predictions
/// <summary>Repository for <see cref="AiItemPrediction"/> records; tenant-filtered. Shared between QuoteItem and JobItem via a single nullable FK — no duplication on quote→job conversion.</summary>
public IRepository<AiItemPrediction> AiItemPredictions =>
@@ -8,8 +8,8 @@ namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Implements <see cref="IDashboardReadService"/> using <see cref="ApplicationDbContext"/> directly.
/// All queries require ThenInclude chains or navigation-property predicates (e.g. JobStatus.StatusCode)
/// that cannot be expressed through the generic <see cref="PowderCoating.Core.Interfaces.IRepository{T}"/>.
/// All queries require navigation-property predicates, eager loading, or aggregate projections that
/// do not fit the generic repository abstraction well.
/// </summary>
public class DashboardReadService : IDashboardReadService
{
@@ -21,6 +21,18 @@ public class DashboardReadService : IDashboardReadService
"CANCELLED"
];
private static readonly string[] InProgressStatusCodes =
[
"IN_PREPARATION",
"SANDBLASTING",
"MASKING_TAPING",
"CLEANING",
"IN_OVEN",
"COATING",
"CURING",
"QUALITY_CHECK"
];
private readonly ApplicationDbContext _context;
public DashboardReadService(ApplicationDbContext context)
@@ -31,190 +43,406 @@ public class DashboardReadService : IDashboardReadService
/// <inheritdoc/>
public async Task<DashboardIndexData> GetIndexDataAsync(DateTime today)
{
var startOfMonth = new DateTime(today.Year, today.Month, 1);
var endOfMonth = startOfMonth.AddMonths(1).AddDays(-1);
var tomorrow = today.AddDays(1);
var lookAheadDate = today.AddDays(7);
var startOfMonth = new DateTime(today.Year, today.Month, 1);
var startOfNextMonth = startOfMonth.AddMonths(1);
var lookAheadInclusive = today.AddDays(8);
var last30Days = today.AddDays(-30);
var days30Ago = today.AddDays(-30);
var days60Ago = today.AddDays(-60);
var days90Ago = today.AddDays(-90);
var openInvoiceStatuses = new[] { InvoiceStatus.Sent, InvoiceStatus.PartiallyPaid, InvoiceStatus.Overdue };
// All active jobs (for today/overdue/in-progress panels)
var activeJobs = await _context.Jobs
var activeJobsBase = _context.Jobs
.AsNoTracking()
.Include(j => j.Customer)
.Include(j => j.AssignedUser)
.Include(j => j.JobStatus)
.Include(j => j.JobPriority)
.Where(j => !CompletedStatusCodes.Contains(j.JobStatus.StatusCode))
.Where(j => !CompletedStatusCodes.Contains(j.JobStatus.StatusCode));
var todaysJobsFilter = activeJobsBase.Where(j =>
(j.ScheduledDate.HasValue && j.ScheduledDate.Value >= today && j.ScheduledDate.Value < tomorrow) ||
(j.DueDate.HasValue && j.DueDate.Value >= today && j.DueDate.Value < tomorrow));
var overdueJobsFilter = activeJobsBase.Where(j =>
j.DueDate.HasValue && j.DueDate.Value < today);
var inProgressJobsFilter = activeJobsBase.Where(j =>
InProgressStatusCodes.Contains(j.JobStatus.StatusCode));
var activeJobsCount = await activeJobsBase.CountAsync();
var todaysJobsCount = await todaysJobsFilter.CountAsync();
var overdueJobsCount = await overdueJobsFilter.CountAsync();
var todaysJobs = await WithDashboardJobIncludes(todaysJobsFilter)
.OrderBy(j => j.JobPriority.DisplayOrder)
.ThenBy(j => j.ScheduledDate ?? j.DueDate)
.Take(10)
.ToListAsync();
// Monthly revenue — sum completed jobs updated in current month
var monthlyRevenue = await _context.Jobs
.Include(j => j.JobStatus)
.Where(j => CompletedStatusCodes.Contains(j.JobStatus.StatusCode)
&& j.UpdatedAt >= startOfMonth
&& j.UpdatedAt <= endOfMonth)
.SumAsync(j => j.FinalPrice);
var overdueJobs = await WithDashboardJobIncludes(overdueJobsFilter)
.OrderBy(j => j.JobPriority.DisplayOrder)
.ThenBy(j => j.DueDate)
.Take(10)
.ToListAsync();
// Today's appointments (non-cancelled)
var todaysAppointments = await _context.Appointments
var inProgressJobs = await WithDashboardJobIncludes(inProgressJobsFilter)
.OrderBy(j => j.JobPriority.DisplayOrder)
.ThenBy(j => j.ScheduledDate)
.Take(10)
.ToListAsync();
var todaysAppointmentsBase = _context.Appointments
.AsNoTracking()
.Include(a => a.Customer)
.Include(a => a.AppointmentType)
.Include(a => a.AppointmentStatus)
.Include(a => a.AssignedUser)
.Where(a => a.ScheduledStartTime >= today && a.ScheduledStartTime < tomorrow
&& a.AppointmentStatus.StatusCode != "CANCELLED")
.Where(a =>
a.ScheduledStartTime >= today &&
a.ScheduledStartTime < tomorrow &&
a.AppointmentStatus.StatusCode != "CANCELLED");
var todaysAppointmentsCount = await todaysAppointmentsBase.CountAsync();
var todaysAppointments = await WithDashboardAppointmentIncludes(todaysAppointmentsBase)
.OrderBy(a => a.ScheduledStartTime)
.Take(10)
.ToListAsync();
// Upcoming/overdue maintenance
var upcomingMaintenance = await _context.MaintenanceRecords
var lowStockBase = _context.InventoryItems
.AsNoTracking()
.Include(m => m.Equipment)
.Include(m => m.AssignedUser)
.Where(m => (m.Status == MaintenanceStatus.Scheduled
|| m.Status == MaintenanceStatus.InProgress
|| m.Status == MaintenanceStatus.Overdue)
&& (m.Status == MaintenanceStatus.Overdue || m.ScheduledDate <= lookAheadDate))
.Where(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
var lowStockCount = await lowStockBase.CountAsync();
var lowStockItems = await lowStockBase
.OrderBy(i => i.QuantityOnHand)
.Take(10)
.ToListAsync();
var maintenanceBase = _context.MaintenanceRecords
.AsNoTracking()
.Where(m =>
(m.Status == MaintenanceStatus.Scheduled ||
m.Status == MaintenanceStatus.InProgress ||
m.Status == MaintenanceStatus.Overdue) &&
(m.Status == MaintenanceStatus.Overdue || m.ScheduledDate < lookAheadInclusive));
var pendingMaintenanceCount = await maintenanceBase.CountAsync();
var upcomingMaintenance = await WithDashboardMaintenanceIncludes(maintenanceBase)
.OrderBy(m => m.Status == MaintenanceStatus.Overdue ? 0 : 1)
.ThenByDescending(m => m.Priority)
.ThenBy(m => m.ScheduledDate)
.Take(10)
.ToListAsync();
// Pending quotes (SENT status)
var pendingQuotes = await _context.Quotes
var pendingQuotesBase = _context.Quotes
.AsNoTracking()
.Include(q => q.Customer)
.Include(q => q.QuoteStatus)
.Where(q => q.QuoteStatus.StatusCode == "SENT")
.Where(q => q.QuoteStatus.StatusCode == "SENT");
var pendingQuotesCount = await pendingQuotesBase.CountAsync();
var pendingQuoteValue = await pendingQuotesBase
.Select(q => (decimal?)q.Total)
.SumAsync() ?? 0m;
var pendingQuotes = await WithDashboardQuoteIncludes(pendingQuotesBase)
.OrderBy(q => q.ExpirationDate)
.ThenBy(q => q.QuoteDate)
.Take(10)
.ToListAsync();
// Open invoices (for AR aging + overdue list)
var openInvoices = await _context.Invoices
.AsNoTracking()
.Include(i => i.Customer)
.Where(i => openInvoiceStatuses.Contains(i.Status))
var expiringQuotes = await WithDashboardQuoteIncludes(pendingQuotesBase)
.Where(q => q.ExpirationDate.HasValue &&
q.ExpirationDate.Value >= today &&
q.ExpirationDate.Value < lookAheadInclusive)
.OrderBy(q => q.ExpirationDate)
.ThenBy(q => q.QuoteDate)
.Take(10)
.ToListAsync();
// Invoiced this month
var activeCustomersCount = await _context.Customers
.AsNoTracking()
.CountAsync(c => c.IsActive);
var monthlyRevenue = await _context.Jobs
.AsNoTracking()
.Where(j =>
CompletedStatusCodes.Contains(j.JobStatus.StatusCode) &&
j.UpdatedAt >= startOfMonth &&
j.UpdatedAt < startOfNextMonth)
.Select(j => (decimal?)j.FinalPrice)
.SumAsync() ?? 0m;
var openInvoicesBase = _context.Invoices
.AsNoTracking()
.Where(i => openInvoiceStatuses.Contains(i.Status));
var overdueInvoicesBase = openInvoicesBase.Where(i =>
i.DueDate.HasValue && i.DueDate.Value < today);
var outstandingAr = await openInvoicesBase
.Select(i => (decimal?)(i.Total - i.AmountPaid - i.CreditApplied - i.GiftCertificateRedeemed))
.SumAsync() ?? 0m;
var invoicedThisMonth = await _context.Invoices
.Where(i => i.Status != InvoiceStatus.Draft
&& i.Status != InvoiceStatus.Voided
&& i.Status != InvoiceStatus.WrittenOff
&& i.InvoiceDate >= startOfMonth
&& i.InvoiceDate <= endOfMonth)
.SumAsync(i => i.Total);
.AsNoTracking()
.Where(i =>
i.Status != InvoiceStatus.Draft &&
i.Status != InvoiceStatus.Voided &&
i.Status != InvoiceStatus.WrittenOff &&
i.InvoiceDate >= startOfMonth &&
i.InvoiceDate < startOfNextMonth)
.Select(i => (decimal?)i.Total)
.SumAsync() ?? 0m;
// Collected this month
var collectedThisMonth = await _context.Payments
.Where(p => p.PaymentDate >= startOfMonth && p.PaymentDate <= endOfMonth)
.SumAsync(p => p.Amount);
.AsNoTracking()
.Where(p => p.PaymentDate >= startOfMonth && p.PaymentDate < startOfNextMonth)
.Select(p => (decimal?)p.Amount)
.SumAsync() ?? 0m;
var overdueInvoicesCount = await overdueInvoicesBase.CountAsync();
var overdueInvoicesAmount = await overdueInvoicesBase
.Select(i => (decimal?)(i.Total - i.AmountPaid - i.CreditApplied - i.GiftCertificateRedeemed))
.SumAsync() ?? 0m;
var aging = new DashboardArAgingData(
Current: await openInvoicesBase
.Where(i => !i.DueDate.HasValue || i.DueDate.Value >= today)
.Select(i => (decimal?)(i.Total - i.AmountPaid - i.CreditApplied - i.GiftCertificateRedeemed))
.SumAsync() ?? 0m,
Days1To30: await openInvoicesBase
.Where(i => i.DueDate.HasValue && i.DueDate.Value < today && i.DueDate.Value >= days30Ago)
.Select(i => (decimal?)(i.Total - i.AmountPaid - i.CreditApplied - i.GiftCertificateRedeemed))
.SumAsync() ?? 0m,
Days31To60: await openInvoicesBase
.Where(i => i.DueDate.HasValue && i.DueDate.Value < days30Ago && i.DueDate.Value >= days60Ago)
.Select(i => (decimal?)(i.Total - i.AmountPaid - i.CreditApplied - i.GiftCertificateRedeemed))
.SumAsync() ?? 0m,
Days61To90: await openInvoicesBase
.Where(i => i.DueDate.HasValue && i.DueDate.Value < days60Ago && i.DueDate.Value >= days90Ago)
.Select(i => (decimal?)(i.Total - i.AmountPaid - i.CreditApplied - i.GiftCertificateRedeemed))
.SumAsync() ?? 0m,
DaysOver90: await openInvoicesBase
.Where(i => i.DueDate.HasValue && i.DueDate.Value < days90Ago)
.Select(i => (decimal?)(i.Total - i.AmountPaid - i.CreditApplied - i.GiftCertificateRedeemed))
.SumAsync() ?? 0m
);
var overdueInvoices = await openInvoicesBase
.Where(i => i.DueDate.HasValue && i.DueDate.Value < today)
.Include(i => i.Customer)
.OrderBy(i => i.DueDate)
.Take(6)
.ToListAsync();
// Recent payments with Invoice → Customer
var recentPayments = await _context.Payments
.AsNoTracking()
.Include(p => p.Invoice).ThenInclude(i => i!.Customer)
.Include(p => p.Invoice)
.ThenInclude(i => i!.Customer)
.OrderByDescending(p => p.PaymentDate)
.Take(6)
.ToListAsync();
// Recent quotes (last 30 days)
var recentQuotes = await _context.Quotes
var equipmentAlerts = await _context.Equipment
.AsNoTracking()
.Include(q => q.Customer)
.Include(q => q.QuoteStatus)
.Where(q => q.CreatedAt >= last30Days)
.Where(e =>
e.Status == EquipmentStatus.NeedsMaintenance ||
e.Status == EquipmentStatus.OutOfService)
.OrderByDescending(e => e.Status == EquipmentStatus.OutOfService ? 1 : 0)
.ThenBy(e => e.EquipmentName)
.Take(5)
.ToListAsync();
var recentQuotes = await WithDashboardQuoteIncludes(_context.Quotes
.AsNoTracking()
.Where(q => q.CreatedAt >= last30Days))
.OrderByDescending(q => q.CreatedAt)
.Take(5)
.ToListAsync();
// Recent jobs (last 30 days)
var recentJobs = await _context.Jobs
.AsNoTracking()
.Include(j => j.Customer)
.Include(j => j.JobStatus)
.Where(j => j.CreatedAt >= last30Days)
var recentJobs = await WithDashboardJobIncludes(_context.Jobs
.AsNoTracking()
.Where(j => j.CreatedAt >= last30Days))
.OrderByDescending(j => j.CreatedAt)
.Take(5)
.ToListAsync();
// Jobs needing powder (not yet ordered, insufficient stock)
var jobsNeedingPowder = await _context.Jobs
.AsNoTracking()
.Include(j => j.Customer)
.Include(j => j.JobStatus)
.Include(j => j.JobItems)
.ThenInclude(i => i.Coats)
.ThenInclude(c => c.InventoryItem)
.ThenInclude(inv => inv!.PrimaryVendor)
.Include(j => j.JobItems)
.ThenInclude(i => i.Coats)
.ThenInclude(c => c.Vendor)
.Where(j => !j.IsDeleted
&& !CompletedStatusCodes.Contains(j.JobStatus.StatusCode)
&& j.JobItems.Any(i => i.Coats.Any(c =>
!c.IsDeleted &&
!c.PowderOrdered &&
c.PowderToOrder > 0 &&
(c.InventoryItemId == null || c.InventoryItem!.QuantityOnHand < c.PowderToOrder))))
.ToListAsync();
var powderOrdersNeeded = await BuildPowderOrderQuery(orderedOnly: false).ToListAsync();
var powderOrdersPlaced = await BuildPowderOrderQuery(orderedOnly: true).ToListAsync();
// Jobs with powder already ordered but not yet received
var jobsWithOrderedPowder = await _context.Jobs
var billsDueBase = _context.Bills
.AsNoTracking()
.Include(j => j.Customer)
.Include(j => j.JobStatus)
.Include(j => j.JobItems)
.ThenInclude(i => i.Coats)
.ThenInclude(c => c.InventoryItem)
.ThenInclude(inv => inv!.PrimaryVendor)
.Include(j => j.JobItems)
.ThenInclude(i => i.Coats)
.ThenInclude(c => c.Vendor)
.Where(j => !j.IsDeleted
&& !CompletedStatusCodes.Contains(j.JobStatus.StatusCode)
&& j.JobItems.Any(i => i.Coats.Any(c =>
!c.IsDeleted &&
c.PowderOrdered &&
!c.PowderReceived)))
.ToListAsync();
.Where(b =>
(b.Status == BillStatus.Open || b.Status == BillStatus.PartiallyPaid) &&
b.Total > b.AmountPaid);
// Bills due (open/partial, balance remaining)
var billsDue = await _context.Bills
.AsNoTracking()
var billsDueCount = await billsDueBase.CountAsync();
var billsDueAmount = await billsDueBase
.Select(b => (decimal?)(b.Total - b.AmountPaid))
.SumAsync() ?? 0m;
var billsDue = await billsDueBase
.Include(b => b.Vendor)
.Where(b => (b.Status == BillStatus.Open || b.Status == BillStatus.PartiallyPaid)
&& b.Total > b.AmountPaid)
.OrderBy(b => b.DueDate)
.Take(15)
.ToListAsync();
// Random tip of the day
var tips = await _context.DashboardTips.Where(t => t.IsActive).ToListAsync();
var tipOfTheDay = tips.Count > 0 ? tips[Random.Shared.Next(tips.Count)].TipText : null;
var tipCount = await _context.DashboardTips
.AsNoTracking()
.CountAsync(t => t.IsActive);
string? tipOfTheDay = null;
if (tipCount > 0)
{
var tipIndex = Random.Shared.Next(tipCount);
tipOfTheDay = await _context.DashboardTips
.AsNoTracking()
.Where(t => t.IsActive)
.OrderBy(t => t.Id)
.Select(t => t.TipText)
.Skip(tipIndex)
.FirstOrDefaultAsync();
}
return new DashboardIndexData(
ActiveJobs: activeJobs,
MonthlyRevenue: monthlyRevenue,
ActiveJobsCount: activeJobsCount,
TodaysJobsCount: todaysJobsCount,
TodaysJobs: todaysJobs,
OverdueJobsCount: overdueJobsCount,
OverdueJobs: overdueJobs,
InProgressJobs: inProgressJobs,
TodaysAppointmentsCount: todaysAppointmentsCount,
TodaysAppointments: todaysAppointments,
LowStockCount: lowStockCount,
LowStockItems: lowStockItems,
PendingMaintenanceCount: pendingMaintenanceCount,
UpcomingMaintenance: upcomingMaintenance,
PendingQuotesCount: pendingQuotesCount,
PendingQuoteValue: pendingQuoteValue,
PendingQuotes: pendingQuotes,
OpenInvoices: openInvoices,
ExpiringQuotes: expiringQuotes,
ActiveCustomersCount: activeCustomersCount,
MonthlyRevenue: monthlyRevenue,
OutstandingAr: outstandingAr,
InvoicedThisMonth: invoicedThisMonth,
CollectedThisMonth: collectedThisMonth,
OverdueInvoicesCount: overdueInvoicesCount,
OverdueInvoicesAmount: overdueInvoicesAmount,
ArAging: aging,
OverdueInvoices: overdueInvoices,
RecentPayments: recentPayments,
RecentQuotes: recentQuotes,
RecentJobs: recentJobs,
JobsNeedingPowder: jobsNeedingPowder,
JobsWithOrderedPowder: jobsWithOrderedPowder,
EquipmentAlerts: equipmentAlerts,
PowderOrdersNeeded: powderOrdersNeeded,
PowderOrdersPlaced: powderOrdersPlaced,
BillsDueCount: billsDueCount,
BillsDueAmount: billsDueAmount,
BillsDue: billsDue,
TipOfTheDay: tipOfTheDay
);
}
/// <inheritdoc/>
public async Task<SuperAdminDashboardData> GetSuperAdminDashboardDataAsync(DateTime today)
{
var companies = _context.Companies
.AsNoTracking()
.IgnoreQueryFilters()
.Where(c => !c.IsDeleted);
var summary = await companies
.GroupBy(_ => 1)
.Select(g => new
{
TotalCompanies = g.Count(),
ActiveCompanies = g.Count(c => c.IsActive),
InactiveCompanies = g.Count(c => !c.IsActive),
ActiveSubscriptions = g.Count(c => c.SubscriptionStatus == SubscriptionStatus.Active),
GracePeriodCount = g.Count(c => c.SubscriptionStatus == SubscriptionStatus.GracePeriod),
ExpiredCount = g.Count(c =>
c.SubscriptionStatus == SubscriptionStatus.Expired ||
c.SubscriptionStatus == SubscriptionStatus.Canceled)
})
.FirstOrDefaultAsync();
var totalUsers = await _context.Users
.Where(u => u.CompanyId > 0)
.CountAsync();
var planConfigs = await _context.SubscriptionPlanConfigs
.AsNoTracking()
.IgnoreQueryFilters()
.Where(c => c.IsActive)
.OrderBy(c => c.SortOrder)
.Select(c => new { c.Plan, c.DisplayName })
.ToListAsync();
var planLookup = planConfigs.ToDictionary(c => c.Plan, c => c.DisplayName);
string PlanName(int plan) => planLookup.TryGetValue(plan, out var name) ? name : plan.ToString();
var planCounts = await companies
.GroupBy(c => c.SubscriptionPlan)
.Select(g => new { Plan = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.Plan, x => x.Count);
var companyAlerts = await companies
.Where(c => c.SubscriptionEndDate.HasValue && c.SubscriptionEndDate.Value.Date < today)
.OrderBy(c => c.SubscriptionEndDate)
.Take(20)
.Select(c => new
{
c.Id,
c.CompanyName,
c.SubscriptionPlan,
c.SubscriptionStatus,
c.SubscriptionEndDate,
c.IsActive
})
.ToListAsync();
var recentCompanies = await companies
.OrderByDescending(c => c.CreatedAt)
.Take(10)
.Select(c => new
{
c.Id,
c.CompanyName,
c.SubscriptionPlan,
c.SubscriptionStatus,
c.IsActive,
c.CreatedAt
})
.ToListAsync();
var planDistribution = planConfigs.ToDictionary(
c => c.Plan,
c => new DashboardPlanDistributionData(
c.DisplayName,
planCounts.TryGetValue(c.Plan, out var count) ? count : 0));
return new SuperAdminDashboardData(
TotalCompanies: summary?.TotalCompanies ?? 0,
ActiveCompanies: summary?.ActiveCompanies ?? 0,
InactiveCompanies: summary?.InactiveCompanies ?? 0,
TotalUsers: totalUsers,
ActiveSubscriptions: summary?.ActiveSubscriptions ?? 0,
GracePeriodCount: summary?.GracePeriodCount ?? 0,
ExpiredCount: summary?.ExpiredCount ?? 0,
PlanDistribution: planDistribution,
CompanyAlerts: companyAlerts.Select(c => new SuperAdminCompanyAlertData(
c.Id,
c.CompanyName,
c.SubscriptionPlan,
PlanName(c.SubscriptionPlan),
c.SubscriptionStatus,
c.SubscriptionEndDate,
c.SubscriptionEndDate.HasValue ? (int)(today - c.SubscriptionEndDate.Value.Date).TotalDays : 0,
c.IsActive)).ToList(),
RecentCompanies: recentCompanies.Select(c => new SuperAdminRecentCompanyData(
c.Id,
c.CompanyName,
c.SubscriptionPlan,
PlanName(c.SubscriptionPlan),
c.SubscriptionStatus,
c.IsActive,
c.CreatedAt)).ToList()
);
}
/// <inheritdoc/>
public async Task<int> GetTotalUserCountAsync()
{
@@ -222,4 +450,76 @@ public class DashboardReadService : IDashboardReadService
.Where(u => u.CompanyId > 0)
.CountAsync();
}
private static IQueryable<Job> WithDashboardJobIncludes(IQueryable<Job> query) =>
query.Include(j => j.Customer)
.Include(j => j.AssignedUser)
.Include(j => j.JobStatus)
.Include(j => j.JobPriority);
private static IQueryable<Appointment> WithDashboardAppointmentIncludes(IQueryable<Appointment> query) =>
query.Include(a => a.Customer)
.Include(a => a.AppointmentType)
.Include(a => a.AppointmentStatus)
.Include(a => a.AssignedUser);
private static IQueryable<MaintenanceRecord> WithDashboardMaintenanceIncludes(IQueryable<MaintenanceRecord> query) =>
query.Include(m => m.Equipment)
.Include(m => m.AssignedUser);
private static IQueryable<Quote> WithDashboardQuoteIncludes(IQueryable<Quote> query) =>
query.Include(q => q.Customer)
.Include(q => q.QuoteStatus);
private IQueryable<DashboardPowderOrderLineData> BuildPowderOrderQuery(bool orderedOnly)
{
var coats = _context.JobItemCoats
.AsNoTracking()
.Where(c =>
!c.IsDeleted &&
!c.JobItem.IsDeleted &&
!c.JobItem.Job.IsDeleted &&
!CompletedStatusCodes.Contains(c.JobItem.Job.JobStatus.StatusCode));
coats = orderedOnly
? coats.Where(c => c.PowderOrdered && !c.PowderReceived)
: coats.Where(c =>
!c.PowderOrdered &&
c.PowderToOrder > 0 &&
(c.InventoryItemId == null || (c.InventoryItem != null && c.InventoryItem.QuantityOnHand < c.PowderToOrder)));
return coats.Select(c => new DashboardPowderOrderLineData(
c.Id,
c.JobItem.JobId,
c.JobItem.Job.JobNumber,
c.JobItem.Job.Customer != null
? (c.JobItem.Job.Customer.CompanyName ?? c.JobItem.Job.Customer.ContactFirstName ?? "Unknown")
: "Unknown",
c.CoatName,
c.ColorName ?? (c.InventoryItem != null ? c.InventoryItem.ColorName : null),
c.ColorCode ?? (c.InventoryItem != null ? c.InventoryItem.ColorCode : null),
c.Finish ?? (c.InventoryItem != null ? c.InventoryItem.Finish : null),
c.InventoryItem != null ? c.InventoryItem.SKU : null,
c.PowderToOrder ?? 0m,
c.PowderCostPerLb ?? (c.InventoryItem != null ? c.InventoryItem.UnitCost : null),
orderedOnly ? c.PowderOrderedAt : null,
c.InventoryItemId.HasValue,
c.VendorId ?? (c.InventoryItem != null ? c.InventoryItem.PrimaryVendorId : null),
c.Vendor != null
? c.Vendor.CompanyName
: c.InventoryItem != null && c.InventoryItem.PrimaryVendor != null
? c.InventoryItem.PrimaryVendor.CompanyName
: null,
c.Vendor != null
? c.Vendor.Phone
: c.InventoryItem != null && c.InventoryItem.PrimaryVendor != null
? c.InventoryItem.PrimaryVendor.Phone
: null,
c.Vendor != null
? c.Vendor.Email
: c.InventoryItem != null && c.InventoryItem.PrimaryVendor != null
? c.InventoryItem.PrimaryVendor.Email
: null
));
}
}
@@ -168,6 +168,89 @@ public class NotificationService : INotificationService
}
}
/// <summary>
/// Sends the quote approval link to the customer via SMS.
/// Prospect quotes use ProspectPhone; registered customers require NotifyBySms + a phone number.
/// Returns (success, errorMessage) so the controller can surface the result.
/// </summary>
public async Task<(bool Success, string? Error)> NotifyQuoteSentSmsAsync(Quote quote)
{
try
{
var (companyName, company) = await GetCompanyAsync(quote.CompanyId);
if (!await IsSmsAllowedForCompanyAsync(company))
return (false, "SMS is not enabled for this account.");
var baseUrl = await GetBaseUrlAsync();
var approvalUrl = !string.IsNullOrEmpty(quote.ApprovalToken) && !string.IsNullOrEmpty(baseUrl)
? $"{baseUrl}/quote-approval/{quote.ApprovalToken}"
: null;
if (string.IsNullOrEmpty(approvalUrl))
return (false, "No approval link available for this quote.");
string? smsPhone;
string recipientName;
int? customerId = null;
if (quote.CustomerId == null)
{
// Prospect — use ProspectPhone; no opt-in check (they explicitly provided a phone)
smsPhone = quote.ProspectPhone;
if (string.IsNullOrWhiteSpace(smsPhone))
return (false, "No phone number on file for this prospect.");
recipientName = !string.IsNullOrWhiteSpace(quote.ProspectContactName)
? quote.ProspectContactName
: quote.ProspectCompanyName ?? "Valued Customer";
}
else
{
var customer = await _context.Customers.FindAsync(quote.CustomerId.Value);
if (customer == null) return (false, "Customer not found.");
recipientName = GetCustomerDisplayName(customer);
customerId = customer.Id;
smsPhone = customer.MobilePhone ?? customer.Phone;
if (string.IsNullOrWhiteSpace(smsPhone))
return (false, $"{recipientName} has no phone number on file.");
if (!customer.NotifyBySms)
{
await WriteLog(SkippedLog(NotificationChannel.Sms, NotificationType.QuoteSent,
recipientName, smsPhone, quote.CompanyId, customerId: customer.Id, quoteId: quote.Id));
return (false, $"{recipientName} has SMS notifications disabled.");
}
}
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 (success, error) = await _smsService.SendSmsAsync(smsPhone, message);
await WriteLog(new NotificationLog
{
Channel = NotificationChannel.Sms,
NotificationType = NotificationType.QuoteSent,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = recipientName,
Recipient = smsPhone,
Message = message,
ErrorMessage = error,
SentAt = DateTime.UtcNow,
CustomerId = customerId,
QuoteId = quote.Id,
CompanyId = quote.CompanyId
});
return (success, error);
}
catch (Exception ex)
{
_logger.LogError(ex, "NotifyQuoteSentSmsAsync failed for quote {QuoteId}", quote.Id);
return (false, "An unexpected error occurred while sending the SMS.");
}
}
/// <summary>
/// Sends a confirmation email to the customer when a quote is approved (either by the
/// customer via the online portal or internally by staff). Prospect quotes (no CustomerId)
@@ -310,48 +393,6 @@ public class NotificationService : INotificationService
customerName, customer.Email, job.CompanyId, customerId: customer.Id, jobId: job.Id));
}
// SMS only for READY_FOR_PICKUP
var smsFeatureEnabled = string.Equals(await _platformSettings.GetAsync(PlatformSettingKeys.SmsEnabled), "true", StringComparison.OrdinalIgnoreCase);
if (smsFeatureEnabled && newStatusCode == "READY_FOR_PICKUP")
{
var smsPhone = customer.MobilePhone ?? customer.Phone;
if (customer.NotifyBySms && !string.IsNullOrWhiteSpace(smsPhone))
{
var smsValues = new Dictionary<string, string>
{
["companyName"] = companyName,
["customerName"] = customerName,
["jobNumber"] = job.JobNumber ?? string.Empty
};
var smsMessage = await GetRenderedSmsAsync(
job.CompanyId, NotificationType.JobReadyForPickup, smsValues,
$"{companyName}: Job {job.JobNumber} is ready for pickup! Reply STOP to opt out.");
var (success, error) = await _smsService.SendSmsAsync(smsPhone, smsMessage);
await WriteLog(new NotificationLog
{
Channel = NotificationChannel.Sms,
NotificationType = NotificationType.JobReadyForPickup,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName,
Recipient = smsPhone,
Message = smsMessage,
ErrorMessage = error,
SentAt = DateTime.UtcNow,
CustomerId = customer.Id,
JobId = job.Id,
CompanyId = job.CompanyId
});
}
else if (!string.IsNullOrWhiteSpace(customer.MobilePhone ?? customer.Phone))
{
await WriteLog(SkippedLog(NotificationChannel.Sms, NotificationType.JobReadyForPickup,
customerName, customer.MobilePhone ?? customer.Phone!, job.CompanyId,
customerId: customer.Id, jobId: job.Id));
}
}
}
catch (Exception ex)
{
@@ -364,7 +405,7 @@ public class NotificationService : INotificationService
/// because the completion message may include a link to pay the invoice online, and uses a
/// dedicated JobCompleted template with different wording than a mid-workflow status change.
/// </summary>
public async Task NotifyJobCompletedAsync(Job job)
public async Task NotifyJobCompletedAsync(Job job, bool suppressSms = false)
{
try
{
@@ -419,45 +460,48 @@ public class NotificationService : INotificationService
customerName, customer.Email, job.CompanyId, customerId: customer.Id, jobId: job.Id));
}
// SMS
var smsOn = string.Equals(await _platformSettings.GetAsync(PlatformSettingKeys.SmsEnabled), "true", StringComparison.OrdinalIgnoreCase);
if (smsOn)
// SMS — skip when the caller (Admin/Manager) will handle it via the compose modal
if (!suppressSms)
{
var smsPhone = customer.MobilePhone ?? customer.Phone;
if (customer.NotifyBySms && !string.IsNullOrWhiteSpace(smsPhone))
var smsAllowed = await IsSmsAllowedForCompanyAsync(company);
if (smsAllowed)
{
var smsValues = new Dictionary<string, string>
var smsPhone = customer.MobilePhone ?? customer.Phone;
if (customer.NotifyBySms && !string.IsNullOrWhiteSpace(smsPhone))
{
["companyName"] = companyName,
["customerName"] = customerName,
["jobNumber"] = job.JobNumber ?? string.Empty
};
var smsValues = new Dictionary<string, string>
{
["companyName"] = companyName,
["customerName"] = customerName,
["jobNumber"] = job.JobNumber ?? string.Empty
};
var smsMessage = await GetRenderedSmsAsync(
job.CompanyId, NotificationType.JobCompleted, smsValues,
$"{companyName}: Job {job.JobNumber} is done and ready for pickup! Reply STOP to opt out.");
var smsMessage = await GetRenderedSmsAsync(
job.CompanyId, NotificationType.JobCompleted, smsValues,
$"{companyName}: Job {job.JobNumber} is done and ready for pickup! Reply STOP to opt out.");
var (success, error) = await _smsService.SendSmsAsync(smsPhone, smsMessage);
var (success, error) = await _smsService.SendSmsAsync(smsPhone, smsMessage);
await WriteLog(new NotificationLog
await WriteLog(new NotificationLog
{
Channel = NotificationChannel.Sms,
NotificationType = NotificationType.JobCompleted,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName,
Recipient = smsPhone,
Message = smsMessage,
ErrorMessage = error,
SentAt = DateTime.UtcNow,
CustomerId = customer.Id,
JobId = job.Id,
CompanyId = job.CompanyId
});
}
else if (!string.IsNullOrWhiteSpace(smsPhone))
{
Channel = NotificationChannel.Sms,
NotificationType = NotificationType.JobCompleted,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName,
Recipient = smsPhone,
Message = smsMessage,
ErrorMessage = error,
SentAt = DateTime.UtcNow,
CustomerId = customer.Id,
JobId = job.Id,
CompanyId = job.CompanyId
});
}
else if (!string.IsNullOrWhiteSpace(smsPhone))
{
await WriteLog(SkippedLog(NotificationChannel.Sms, NotificationType.JobCompleted,
customerName, smsPhone, job.CompanyId, customerId: customer.Id, jobId: job.Id));
await WriteLog(SkippedLog(NotificationChannel.Sms, NotificationType.JobCompleted,
customerName, smsPhone, job.CompanyId, customerId: customer.Id, jobId: job.Id));
}
}
}
}
@@ -467,6 +511,90 @@ public class NotificationService : INotificationService
}
}
/// <summary>
/// Renders the job-completed SMS text without sending it, for admin review before manual send.
/// Returns null when SMS is not gated on for the company or the customer has not opted in to SMS.
/// </summary>
public async Task<string?> RenderJobCompletedSmsAsync(Job job)
{
try
{
var customer = job.Customer ?? await _context.Customers.FindAsync(job.CustomerId);
if (customer == null) return null;
var (companyName, company) = await GetCompanyAsync(job.CompanyId);
if (!await IsSmsAllowedForCompanyAsync(company)) return null;
var smsPhone = customer.MobilePhone ?? customer.Phone;
if (!customer.NotifyBySms || string.IsNullOrWhiteSpace(smsPhone)) return null;
var customerName = GetCustomerDisplayName(customer);
var smsValues = new Dictionary<string, string>
{
["companyName"] = companyName,
["customerName"] = customerName,
["jobNumber"] = job.JobNumber ?? string.Empty
};
return await GetRenderedSmsAsync(
job.CompanyId, NotificationType.JobCompleted, smsValues,
$"{companyName}: Job {job.JobNumber} is done and ready for pickup! Reply STOP to opt out.");
}
catch (Exception ex)
{
_logger.LogError(ex, "RenderJobCompletedSmsAsync failed for job {JobId}", job.Id);
return null;
}
}
/// <summary>
/// Sends a manually-composed SMS for a job (Admin/Manager compose-before-send path).
/// Appends opt-out instructions if not already present, then sends and logs the result.
/// </summary>
public async Task<(bool Success, string? Error)> SendJobSmsAsync(Job job, string message)
{
try
{
var customer = job.Customer ?? await _context.Customers.FindAsync(job.CustomerId);
if (customer == null) return (false, "Customer not found.");
var smsPhone = customer.MobilePhone ?? customer.Phone;
if (string.IsNullOrWhiteSpace(smsPhone)) return (false, "Customer has no phone number on file.");
var (_, company) = await GetCompanyAsync(job.CompanyId);
if (!await IsSmsAllowedForCompanyAsync(company)) return (false, "SMS is not enabled for this company.");
// Ensure TCPA-required opt-out language is present
const string stopSuffix = "Reply STOP to opt out.";
if (!message.Contains("STOP", StringComparison.OrdinalIgnoreCase))
message = message.TrimEnd() + " " + stopSuffix;
var (success, error) = await _smsService.SendSmsAsync(smsPhone, message);
await WriteLog(new NotificationLog
{
Channel = NotificationChannel.Sms,
NotificationType = NotificationType.JobCompleted,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = GetCustomerDisplayName(customer),
Recipient = smsPhone,
Message = message,
ErrorMessage = error,
SentAt = DateTime.UtcNow,
CustomerId = customer.Id,
JobId = job.Id,
CompanyId = job.CompanyId
});
return (success, error);
}
catch (Exception ex)
{
_logger.LogError(ex, "SendJobSmsAsync failed for job {JobId}", job.Id);
return (false, "An unexpected error occurred while sending the SMS.");
}
}
/// <summary>
/// Sends the invoice to the customer with the PDF attached. When Stripe Connect is enabled for
/// the company, includes a "Pay Online" button linking to the Stripe-hosted payment page
@@ -801,13 +929,13 @@ public class NotificationService : INotificationService
/// </summary>
public async Task NotifySmsConsentGrantedAsync(Customer customer)
{
if (!string.Equals(await _platformSettings.GetAsync(PlatformSettingKeys.SmsEnabled), "true", StringComparison.OrdinalIgnoreCase)) return;
try
{
var smsPhone = customer.MobilePhone ?? customer.Phone;
if (string.IsNullOrWhiteSpace(smsPhone)) return;
var (companyName, _) = await GetCompanyAsync(customer.CompanyId);
var (companyName, company) = await GetCompanyAsync(customer.CompanyId);
if (!await IsSmsAllowedForCompanyAsync(company)) return;
var values = new Dictionary<string, string>
{
@@ -971,10 +1099,6 @@ public class NotificationService : INotificationService
"Job {{jobNumber}} Ready for Pickup — {{companyName}}",
"<p>Dear {{customerName}},</p><p>Your job <strong>{{jobNumber}}</strong> is ready for pickup!</p><p>Thank you for choosing {{companyName}}.</p>"
),
[(NotificationType.JobReadyForPickup, NotificationChannel.Sms)] = (
null,
"{{companyName}}: Job {{jobNumber}} is ready for pickup! Reply STOP to opt out."
),
[(NotificationType.JobCompleted, NotificationChannel.Email)] = (
"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>"
@@ -1121,6 +1245,28 @@ public class NotificationService : INotificationService
}
}
/// <summary>
/// Returns true only when all four gating tiers allow SMS for this company:
/// platform kill-switch on → not SuperAdmin-disabled → plan AllowSms → company opted in.
/// </summary>
private async Task<bool> IsSmsAllowedForCompanyAsync(Company? company)
{
var platformOn = string.Equals(
await _platformSettings.GetAsync(PlatformSettingKeys.SmsEnabled),
"true", StringComparison.OrdinalIgnoreCase);
if (!platformOn) return false;
if (company == null) return false;
if (company.SmsDisabledByAdmin) return false;
var planConfig = await _context.SubscriptionPlanConfigs
.AsNoTracking()
.FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan);
if (planConfig?.AllowSms != true) return false;
return company.SmsEnabled;
}
/// <summary>
/// Loads the company entity and its display name. Returns a safe fallback name
/// so templates never render blank company names even if the row is missing.
@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.Interfaces;
using Twilio;
@@ -10,16 +12,24 @@ namespace PowderCoating.Infrastructure.Services;
public class SmsService : ISmsService
{
private readonly IConfiguration _configuration;
private readonly IWebHostEnvironment _env;
private readonly ILogger<SmsService> _logger;
/// <summary>
/// Initializes a new instance of <see cref="SmsService"/>. Twilio credentials are read
/// per call (not cached here) because they may be rotated without restarting the application,
/// and Twilio's client initialization (<c>TwilioClient.Init</c>) is idempotent and cheap.
/// <para>
/// In non-production environments, all outbound messages are redirected to
/// <c>Twilio:DevRedirectPhone</c> (if configured) so real customer numbers are never
/// texted during development or staging. The original destination is prepended to the
/// message body for traceability.
/// </para>
/// </summary>
public SmsService(IConfiguration configuration, ILogger<SmsService> logger)
public SmsService(IConfiguration configuration, IWebHostEnvironment env, ILogger<SmsService> logger)
{
_configuration = configuration;
_env = env;
_logger = logger;
}
@@ -58,6 +68,21 @@ public class SmsService : ISmsService
return (false, "Invalid phone number");
}
// Non-production guard: redirect all messages to the dev phone so real customers
// are never texted outside of production. Double-gated on environment name AND
// the config value so a misconfigured prod deploy can't accidentally redirect.
var devRedirect = _configuration["Twilio:DevRedirectPhone"];
if (!_env.IsProduction() && !string.IsNullOrWhiteSpace(devRedirect))
{
var devPhone = NormalizePhone(devRedirect);
if (!string.IsNullOrEmpty(devPhone))
{
_logger.LogWarning("Non-production environment: redirecting SMS from {Original} to dev number {Dev}", normalizedPhone, devPhone);
message = $"[DEV → {normalizedPhone}] {message}";
normalizedPhone = devPhone;
}
}
try
{
TwilioClient.Init(accountSid, authToken);
@@ -7,6 +7,12 @@ public static class AppConstants
/// <summary>Set to true to enable SMS features throughout the UI.</summary>
public const bool SmsEnabled = false;
/// <summary>
/// Current version of the SMS terms of service. Incrementing this string invalidates all
/// existing company agreements and forces company admins to re-accept before enabling SMS.
/// </summary>
public const string SmsTermsVersion = "1.0";
public static class Roles
{
@@ -126,9 +126,15 @@ public class CompanySettingsController : Controller
var dto = _mapper.Map<CompanySettingsDto>(company);
// Populate AllowOnlinePayments from subscription plan config
// Populate plan-gated feature flags
var planConfig = await _unitOfWork.SubscriptionPlanConfigs.FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan);
dto.AllowOnlinePayments = planConfig?.AllowOnlinePayments ?? false;
dto.AllowSms = planConfig?.AllowSms ?? false;
dto.SmsEnabled = company.SmsEnabled;
dto.SmsDisabledByAdmin = company.SmsDisabledByAdmin;
dto.SmsTermsVersion = AppConstants.SmsTermsVersion;
dto.HasCurrentSmsAgreement = await _unitOfWork.CompanySmsAgreements
.AnyAsync(a => a.CompanyId == companyId.Value && a.TermsVersion == AppConstants.SmsTermsVersion);
// Flag whether Stripe Connect is configured (non-placeholder client ID)
var connectClientId = _configuration["Stripe:Connect:ConnectClientId"];
@@ -619,6 +625,74 @@ public class CompanySettingsController : Controller
}
}
/// <summary>
/// Toggles the company-level SMS opt-in flag. When enabling and no current-version agreement
/// exists, the request must include AgreedToTerms=true and a matching TermsVersion — the
/// acceptance is then recorded as a <see cref="CompanySmsAgreement"/> audit row.
/// Disabling never requires agreement.
/// </summary>
// POST: CompanySettings/UpdateSmsPreferences
[HttpPost]
public async Task<IActionResult> UpdateSmsPreferences([FromBody] UpdateSmsPreferencesDto dto)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false, message = "User does not have a company ID." });
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
if (company == null)
return Json(new { success = false, message = "Company not found." });
if (dto.SmsEnabled)
{
var hasAgreement = await _unitOfWork.CompanySmsAgreements
.AnyAsync(a => a.CompanyId == companyId.Value && a.TermsVersion == AppConstants.SmsTermsVersion);
if (!hasAgreement)
{
// Require explicit acceptance of the current terms version
if (!dto.AgreedToTerms || dto.TermsVersion != AppConstants.SmsTermsVersion)
return Json(new { success = false, requiresAgreement = true, message = "You must accept the SMS terms of service to enable SMS notifications." });
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? string.Empty;
var userName = User.Identity?.Name ?? string.Empty;
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
var ua = Request.Headers.UserAgent.ToString();
var agreement = new CompanySmsAgreement
{
CompanyId = companyId.Value,
AgreedByUserId = userId,
AgreedByUserName = userName,
AgreedAt = DateTime.UtcNow,
IpAddress = ip,
UserAgent = ua,
TermsVersion = AppConstants.SmsTermsVersion,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.CompanySmsAgreements.AddAsync(agreement);
_logger.LogInformation("Company {CompanyId} accepted SMS terms v{Version} by user {UserId} from {Ip}",
companyId, AppConstants.SmsTermsVersion, userId, ip);
}
}
company.SmsEnabled = dto.SmsEnabled;
await _unitOfWork.Companies.UpdateAsync(company);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Company {CompanyId} SMS opt-in set to {SmsEnabled}", companyId, dto.SmsEnabled);
return Json(new { success = true, message = dto.SmsEnabled ? "SMS notifications enabled." : "SMS notifications disabled." });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating SMS preferences");
return Json(new { success = false, message = "An error occurred while saving SMS preferences." });
}
}
/// <summary>
/// Builds a suggested AI profile draft from existing company configuration — company name/location,
/// named ovens, sandblasting capability, shop worker roles, coating inventory categories, and
@@ -26,26 +26,6 @@ public class DashboardController : Controller
private readonly UserManager<ApplicationUser> _userManager;
private readonly ISubscriptionService _subscriptionService;
private static readonly string[] CompletedStatusCodes =
[
"COMPLETED",
"READY_FOR_PICKUP",
"DELIVERED",
"CANCELLED"
];
private static readonly string[] InProgressStatusCodes =
[
"IN_PREPARATION",
"SANDBLASTING",
"MASKING_TAPING",
"CLEANING",
"IN_OVEN",
"COATING",
"CURING",
"QUALITY_CHECK"
];
public DashboardController(
IUnitOfWork unitOfWork,
ILogger<DashboardController> logger,
@@ -79,48 +59,27 @@ public class DashboardController : Controller
try
{
var today = DateTime.Today;
var lookAheadDate = today.AddDays(7);
var data = await _dashboardRead.GetIndexDataAsync(today);
// ---------------------------------------------------------------
// Job panels — in-memory split of the pre-fetched activeJobs list
// Job panels
// ---------------------------------------------------------------
var todaysJobsFiltered = data.ActiveJobs
.Where(j => (j.ScheduledDate.HasValue && j.ScheduledDate.Value.Date == today) ||
(j.DueDate.HasValue && j.DueDate.Value.Date == today));
var todaysJobsCount = todaysJobsFiltered.Count();
var todaysJobs = todaysJobsFiltered
.OrderBy(j => j.JobPriority.DisplayOrder)
.ThenBy(j => j.ScheduledDate ?? j.DueDate)
.Take(10)
var todaysJobs = data.TodaysJobs
.Select(MapJobDto)
.ToList();
var overdueJobsFiltered = data.ActiveJobs
.Where(j => j.DueDate.HasValue && j.DueDate.Value.Date < today);
var overdueJobsCount = overdueJobsFiltered.Count();
var overdueJobs = overdueJobsFiltered
.OrderBy(j => j.JobPriority.DisplayOrder)
.ThenBy(j => j.DueDate)
.Take(10)
var overdueJobs = data.OverdueJobs
.Select(MapJobDto)
.ToList();
var inProgressJobs = data.ActiveJobs
.Where(j => InProgressStatusCodes.Contains(j.JobStatus.StatusCode))
.OrderBy(j => j.JobPriority.DisplayOrder)
.ThenBy(j => j.ScheduledDate)
.Take(10)
var inProgressJobs = data.InProgressJobs
.Select(MapJobDto)
.ToList();
// ---------------------------------------------------------------
// Appointments
// ---------------------------------------------------------------
var todaysAppointmentsCount = data.TodaysAppointments.Count;
var todaysAppointments = data.TodaysAppointments
.Take(10)
.Select(a => new DashboardAppointmentDto
{
Id = a.Id,
@@ -140,12 +99,7 @@ public class DashboardController : Controller
// ---------------------------------------------------------------
// Low stock items
// ---------------------------------------------------------------
var lowStockAll = await _unitOfWork.InventoryItems.FindAsync(
i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
var lowStockCount = lowStockAll.Count();
var lowStockItems = lowStockAll
.OrderBy(i => i.QuantityOnHand)
.Take(10)
var lowStockItems = data.LowStockItems
.Select(i => new DashboardLowStockDto
{
Id = i.Id,
@@ -177,8 +131,6 @@ public class DashboardController : Controller
// Quotes
// ---------------------------------------------------------------
var pendingQuotes = data.PendingQuotes
.OrderBy(q => q.ExpirationDate)
.Take(10)
.Select(q => new DashboardQuoteDto
{
Id = q.Id,
@@ -195,14 +147,7 @@ public class DashboardController : Controller
StatusDisplayName = q.QuoteStatus.DisplayName
}).ToList();
var pendingQuoteValue = data.PendingQuotes.Sum(q => q.Total);
var expiringQuotes = data.PendingQuotes
.Where(q => q.ExpirationDate.HasValue
&& q.ExpirationDate.Value.Date >= today
&& q.ExpirationDate.Value.Date <= lookAheadDate)
.OrderBy(q => q.ExpirationDate)
.Take(10)
var expiringQuotes = data.ExpiringQuotes
.Select(q => new DashboardQuoteDto
{
Id = q.Id,
@@ -219,26 +164,10 @@ public class DashboardController : Controller
StatusDisplayName = q.QuoteStatus.DisplayName
}).ToList();
// ---------------------------------------------------------------
// Active customers
// ---------------------------------------------------------------
var activeCustomersCount = await _unitOfWork.Customers.CountAsync(c => c.IsActive);
// ---------------------------------------------------------------
// Invoices & AR aging
// ---------------------------------------------------------------
var outstandingAr = data.OpenInvoices.Sum(i => i.BalanceDue);
var overdueInvoicesList = data.OpenInvoices
.Where(i => i.DueDate.HasValue && i.DueDate.Value.Date < today)
.OrderBy(i => i.DueDate)
.ToList();
var overdueInvoicesCount = overdueInvoicesList.Count;
var overdueInvoicesAmount = overdueInvoicesList.Sum(i => i.BalanceDue);
var overdueInvoices = overdueInvoicesList
.Take(6)
var overdueInvoices = data.OverdueInvoices
.Select(i => new DashboardInvoiceDto
{
Id = i.Id,
@@ -252,24 +181,6 @@ public class DashboardController : Controller
})
.ToList();
// AR Aging buckets
decimal agingCurrent = 0, aging1To30 = 0, aging31To60 = 0, aging61To90 = 0, agingOver90 = 0;
foreach (var inv in data.OpenInvoices)
{
if (!inv.DueDate.HasValue || inv.DueDate.Value.Date >= today)
{
agingCurrent += inv.BalanceDue;
}
else
{
var daysLate = (int)(today - inv.DueDate.Value.Date).TotalDays;
if (daysLate <= 30) aging1To30 += inv.BalanceDue;
else if (daysLate <= 60) aging31To60 += inv.BalanceDue;
else if (daysLate <= 90) aging61To90 += inv.BalanceDue;
else agingOver90 += inv.BalanceDue;
}
}
// ---------------------------------------------------------------
// Payments
// ---------------------------------------------------------------
@@ -278,7 +189,7 @@ public class DashboardController : Controller
{
Id = p.Id,
InvoiceId = p.InvoiceId,
InvoiceNumber = p.Invoice?.InvoiceNumber ?? "",
InvoiceNumber = p.Invoice?.InvoiceNumber ?? "-",
CustomerName = p.Invoice?.Customer?.CompanyName
?? $"{p.Invoice?.Customer?.ContactFirstName} {p.Invoice?.Customer?.ContactLastName}".Trim(),
Amount = p.Amount,
@@ -298,11 +209,7 @@ public class DashboardController : Controller
// ---------------------------------------------------------------
// Equipment alerts
// ---------------------------------------------------------------
var equipmentAlerts = (await _unitOfWork.Equipment.FindAsync(
e => e.Status == EquipmentStatus.NeedsMaintenance ||
e.Status == EquipmentStatus.OutOfService))
.OrderByDescending(e => e.Status == EquipmentStatus.OutOfService ? 1 : 0)
.Take(5)
var equipmentAlerts = data.EquipmentAlerts
.Select(e => new DashboardEquipmentAlertDto
{
Id = e.Id,
@@ -359,134 +266,12 @@ public class DashboardController : Controller
// ---------------------------------------------------------------
// Powder orders needed
// ---------------------------------------------------------------
var powderFlat = data.JobsNeedingPowder
.SelectMany(j => j.JobItems
.SelectMany(i => i.Coats
.Where(c => !c.IsDeleted && !c.PowderOrdered && c.PowderToOrder > 0
&& (c.InventoryItemId == null || c.InventoryItem!.QuantityOnHand < c.PowderToOrder))
.Select(c =>
{
var vendor = c.Vendor ?? c.InventoryItem?.PrimaryVendor;
return new
{
CoatId = c.Id,
JobId = j.Id,
JobNumber = j.JobNumber,
CustomerName = j.Customer?.CompanyName ?? j.Customer?.ContactFirstName ?? "Unknown",
CoatName = c.CoatName,
ColorName = c.ColorName ?? c.InventoryItem?.ColorName,
ColorCode = c.ColorCode ?? c.InventoryItem?.ColorCode,
Finish = c.Finish ?? c.InventoryItem?.Finish,
SKU = c.InventoryItem?.SKU,
LbsToOrder = c.PowderToOrder!.Value,
CostPerLb = c.PowderCostPerLb ?? c.InventoryItem?.UnitCost,
VendorId = vendor?.Id,
VendorName = vendor?.CompanyName,
VendorPhone = vendor?.Phone,
VendorEmail = vendor?.Email,
};
})))
.ToList();
var powderOrderGroups = powderFlat
.GroupBy(l => l.VendorId)
.Select(g =>
{
var first = g.First();
return new PowderOrderVendorGroupDto
{
VendorId = g.Key,
VendorName = first.VendorName ?? "No Vendor Assigned",
VendorPhone = first.VendorPhone,
VendorEmail = first.VendorEmail,
TotalLbsNeeded = g.Sum(l => l.LbsToOrder),
TotalEstCost = g.Sum(l => l.CostPerLb.HasValue ? l.LbsToOrder * l.CostPerLb.Value : 0),
Lines = g.Select(l => new PowderOrderLineDto
{
CoatId = l.CoatId,
JobId = l.JobId,
JobNumber = l.JobNumber,
CustomerName = l.CustomerName,
CoatName = l.CoatName,
ColorName = l.ColorName,
ColorCode = l.ColorCode,
Finish = l.Finish,
SKU = l.SKU,
LbsToOrder = l.LbsToOrder,
CostPerLb = l.CostPerLb,
}).OrderBy(l => l.JobNumber).ThenBy(l => l.CoatName).ToList()
};
})
.OrderBy(g => g.VendorName)
.ToList();
var powderOrderGroups = MapPowderOrderGroups(data.PowderOrdersNeeded);
// ---------------------------------------------------------------
// Powder orders placed
// ---------------------------------------------------------------
var placedFlat = data.JobsWithOrderedPowder
.SelectMany(j => j.JobItems
.SelectMany(i => i.Coats
.Where(c => !c.IsDeleted && c.PowderOrdered && !c.PowderReceived)
.Select(c =>
{
var vendor = c.Vendor ?? c.InventoryItem?.PrimaryVendor;
return new
{
CoatId = c.Id,
JobId = j.Id,
JobNumber = j.JobNumber,
CustomerName = j.Customer?.CompanyName ?? j.Customer?.ContactFirstName ?? "Unknown",
CoatName = c.CoatName,
ColorName = c.ColorName ?? c.InventoryItem?.ColorName,
ColorCode = c.ColorCode ?? c.InventoryItem?.ColorCode,
Finish = c.Finish ?? c.InventoryItem?.Finish,
SKU = c.InventoryItem?.SKU,
LbsToOrder = c.PowderToOrder ?? 0m,
CostPerLb = c.PowderCostPerLb ?? c.InventoryItem?.UnitCost,
OrderedAt = c.PowderOrderedAt,
HasInventoryItem = c.InventoryItemId.HasValue,
VendorId = vendor?.Id,
VendorName = vendor?.CompanyName,
VendorPhone = vendor?.Phone,
VendorEmail = vendor?.Email,
};
})))
.ToList();
var powderPlacedGroups = placedFlat
.GroupBy(l => l.VendorId)
.Select(g =>
{
var first = g.First();
return new PowderOrderVendorGroupDto
{
VendorId = g.Key,
VendorName = first.VendorName ?? "No Vendor Assigned",
VendorPhone = first.VendorPhone,
VendorEmail = first.VendorEmail,
TotalLbsNeeded = g.Sum(l => l.LbsToOrder),
TotalEstCost = g.Sum(l => l.CostPerLb.HasValue ? l.LbsToOrder * l.CostPerLb.Value : 0),
Lines = g.Select(l => new PowderOrderLineDto
{
CoatId = l.CoatId,
JobId = l.JobId,
JobNumber = l.JobNumber,
CustomerName = l.CustomerName,
CoatName = l.CoatName,
ColorName = l.ColorName,
ColorCode = l.ColorCode,
Finish = l.Finish,
SKU = l.SKU,
LbsToOrder = l.LbsToOrder,
CostPerLb = l.CostPerLb,
OrderedAt = l.OrderedAt,
HasInventoryItem = l.HasInventoryItem,
VendorId = l.VendorId,
}).OrderBy(l => l.OrderedAt).ThenBy(l => l.JobNumber).ToList()
};
})
.OrderBy(g => g.VendorName)
.ToList();
var powderPlacedGroups = MapPowderOrderGroups(data.PowderOrdersPlaced);
// ---------------------------------------------------------------
// Bills due
@@ -495,7 +280,7 @@ public class DashboardController : Controller
{
Id = b.Id,
BillNumber = b.BillNumber,
VendorName = b.Vendor.CompanyName,
VendorName = b.Vendor?.CompanyName ?? "Unknown",
BalanceDue = b.BalanceDue,
DueDate = b.DueDate,
IsOverdue = b.DueDate.HasValue && b.DueDate.Value.Date < today,
@@ -506,28 +291,28 @@ public class DashboardController : Controller
var vm = new DashboardViewModel
{
// Counts
ActiveJobsCount = data.ActiveJobs.Count,
TodaysJobsCount = todaysJobsCount,
OverdueJobsCount = overdueJobsCount,
TodaysAppointmentsCount = todaysAppointmentsCount,
LowStockCount = lowStockCount,
PendingMaintenanceCount = data.UpcomingMaintenance.Count,
PendingQuotesCount = data.PendingQuotes.Count,
PendingQuoteValue = pendingQuoteValue,
ActiveJobsCount = data.ActiveJobsCount,
TodaysJobsCount = data.TodaysJobsCount,
OverdueJobsCount = data.OverdueJobsCount,
TodaysAppointmentsCount = data.TodaysAppointmentsCount,
LowStockCount = data.LowStockCount,
PendingMaintenanceCount = data.PendingMaintenanceCount,
PendingQuotesCount = data.PendingQuotesCount,
PendingQuoteValue = data.PendingQuoteValue,
MonthlyRevenue = data.MonthlyRevenue,
ActiveCustomersCount = activeCustomersCount,
ActiveCustomersCount = data.ActiveCustomersCount,
// Financial KPIs
OutstandingAr = outstandingAr,
OutstandingAr = data.OutstandingAr,
CollectedThisMonth = data.CollectedThisMonth,
InvoicedThisMonth = data.InvoicedThisMonth,
OverdueInvoicesCount = overdueInvoicesCount,
OverdueInvoicesAmount = overdueInvoicesAmount,
AgingCurrent = agingCurrent,
AgingDays1To30 = aging1To30,
AgingDays31To60 = aging31To60,
AgingDays61To90 = aging61To90,
AgingDaysOver90 = agingOver90,
OverdueInvoicesCount = data.OverdueInvoicesCount,
OverdueInvoicesAmount = data.OverdueInvoicesAmount,
AgingCurrent = data.ArAging.Current,
AgingDays1To30 = data.ArAging.Days1To30,
AgingDays31To60 = data.ArAging.Days31To60,
AgingDays61To90 = data.ArAging.Days61To90,
AgingDaysOver90 = data.ArAging.DaysOver90,
// Sections
TodaysJobs = todaysJobs,
@@ -545,14 +330,14 @@ public class DashboardController : Controller
// Bills Due
BillsDue = billsDue,
BillsDueCount = billsDue.Count,
BillsDueAmount = billsDue.Sum(b => b.BalanceDue),
BillsDueCount = data.BillsDueCount,
BillsDueAmount = data.BillsDueAmount,
// Powder orders
PowderOrdersNeeded = powderOrderGroups,
PowderOrdersNeededCount = powderFlat.Count,
PowderOrdersNeededCount = data.PowderOrdersNeeded.Count,
PowderOrdersPlaced = powderPlacedGroups,
PowderOrdersPlacedCount = placedFlat.Count,
PowderOrdersPlacedCount = data.PowderOrdersPlaced.Count,
TipOfTheDay = data.TipOfTheDay
};
@@ -1040,76 +825,41 @@ public class DashboardController : Controller
try
{
var today = DateTime.Today;
var allCompanies = await _unitOfWork.Companies.GetAllAsync(ignoreQueryFilters: true);
var companies = allCompanies.Where(c => !c.IsDeleted).ToList();
var totalUsers = await _dashboardRead.GetTotalUserCountAsync();
var graceCutoff = today.AddDays(-AppConstants.SubscriptionConstants.GracePeriodDays);
var planConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(
c => c.IsActive, ignoreQueryFilters: true))
.OrderBy(c => c.SortOrder)
.ToList();
var planLookup = planConfigs.ToDictionary(c => c.Plan, c => c.DisplayName);
string PlanName(int plan) => planLookup.TryGetValue(plan, out var name) ? name : plan.ToString();
var companyAlerts = companies
.Where(c => c.SubscriptionEndDate.HasValue && c.SubscriptionEndDate.Value.Date < today)
.OrderBy(c => c.SubscriptionEndDate)
.Take(20)
.Select(c =>
{
var daysOverdue = (int)(today - c.SubscriptionEndDate!.Value.Date).TotalDays;
return new PlatformCompanyAlertDto
{
Id = c.Id,
CompanyName = c.CompanyName,
Plan = c.SubscriptionPlan,
PlanDisplayName = PlanName(c.SubscriptionPlan),
Status = c.SubscriptionStatus,
SubscriptionEndDate = c.SubscriptionEndDate,
DaysOverdue = daysOverdue,
IsActive = c.IsActive
};
})
.ToList();
var recentCompanies = companies
.OrderByDescending(c => c.CreatedAt)
.Take(10)
.Select(c => new PlatformRecentCompanyDto
{
Id = c.Id,
CompanyName = c.CompanyName,
Plan = c.SubscriptionPlan,
PlanDisplayName = PlanName(c.SubscriptionPlan),
Status = c.SubscriptionStatus,
IsActive = c.IsActive,
CreatedAt = c.CreatedAt
})
.ToList();
var planDistribution = planConfigs.ToDictionary(
c => c.Plan,
c => (c.DisplayName, companies.Count(comp => comp.SubscriptionPlan == c.Plan)));
var data = await _dashboardRead.GetSuperAdminDashboardDataAsync(today);
var vm = new SuperAdminDashboardViewModel
{
TotalCompanies = companies.Count,
ActiveCompanies = companies.Count(c => c.IsActive),
InactiveCompanies = companies.Count(c => !c.IsActive),
TotalUsers = totalUsers,
PlanDistribution = planDistribution,
ActiveSubscriptions = companies.Count(c => c.SubscriptionStatus == SubscriptionStatus.Active),
GracePeriodCount = companies.Count(c => c.SubscriptionStatus == SubscriptionStatus.GracePeriod),
ExpiredCount = companies.Count(c =>
c.SubscriptionStatus == SubscriptionStatus.Expired ||
c.SubscriptionStatus == SubscriptionStatus.Canceled),
CompanyAlerts = companyAlerts,
RecentCompanies = recentCompanies
TotalCompanies = data.TotalCompanies,
ActiveCompanies = data.ActiveCompanies,
InactiveCompanies = data.InactiveCompanies,
TotalUsers = data.TotalUsers,
PlanDistribution = data.PlanDistribution.ToDictionary(
kvp => kvp.Key,
kvp => (kvp.Value.DisplayName, kvp.Value.Count)),
ActiveSubscriptions = data.ActiveSubscriptions,
GracePeriodCount = data.GracePeriodCount,
ExpiredCount = data.ExpiredCount,
CompanyAlerts = data.CompanyAlerts.Select(c => new PlatformCompanyAlertDto
{
Id = c.Id,
CompanyName = c.CompanyName,
Plan = c.Plan,
PlanDisplayName = c.PlanDisplayName,
Status = c.Status,
SubscriptionEndDate = c.SubscriptionEndDate,
DaysOverdue = c.DaysOverdue,
IsActive = c.IsActive
}).ToList(),
RecentCompanies = data.RecentCompanies.Select(c => new PlatformRecentCompanyDto
{
Id = c.Id,
CompanyName = c.CompanyName,
Plan = c.Plan,
PlanDisplayName = c.PlanDisplayName,
Status = c.Status,
IsActive = c.IsActive,
CreatedAt = c.CreatedAt
}).ToList()
};
return View(vm);
@@ -1122,6 +872,46 @@ public class DashboardController : Controller
}
}
private static List<PowderOrderVendorGroupDto> MapPowderOrderGroups(
IEnumerable<DashboardPowderOrderLineData> lines) =>
lines.GroupBy(l => l.VendorId)
.Select(g =>
{
var first = g.First();
return new PowderOrderVendorGroupDto
{
VendorId = g.Key,
VendorName = first.VendorName ?? "No Vendor Assigned",
VendorPhone = first.VendorPhone,
VendorEmail = first.VendorEmail,
TotalLbsNeeded = g.Sum(l => l.LbsToOrder),
TotalEstCost = g.Sum(l => l.CostPerLb.HasValue ? l.LbsToOrder * l.CostPerLb.Value : 0m),
Lines = g.Select(l => new PowderOrderLineDto
{
CoatId = l.CoatId,
JobId = l.JobId,
JobNumber = l.JobNumber,
CustomerName = l.CustomerName,
CoatName = l.CoatName,
ColorName = l.ColorName,
ColorCode = l.ColorCode,
Finish = l.Finish,
SKU = l.SKU,
LbsToOrder = l.LbsToOrder,
CostPerLb = l.CostPerLb,
OrderedAt = l.OrderedAt,
HasInventoryItem = l.HasInventoryItem,
VendorId = l.VendorId
})
.OrderBy(l => l.OrderedAt ?? DateTime.MinValue)
.ThenBy(l => l.JobNumber)
.ThenBy(l => l.CoatName)
.ToList()
};
})
.OrderBy(g => g.VendorName)
.ToList();
/// <summary>
/// Projects a <see cref="Core.Entities.Job"/> into a lightweight <see cref="DashboardJobDto"/>
/// for use in dashboard job lists. Centralising the mapping in one static helper ensures that
@@ -501,6 +501,14 @@ public class JobsController : Controller
ViewBag.CanResyncFromQuote = preProductionCodes.Contains(job.JobStatus?.StatusCode ?? "");
}
// SMS compose modal: pass pending preview (set by CompleteJob for Admin/Manager) and role flags
if (TempData["PendingSmsPreview"] is string smsPreview)
ViewBag.PendingSmsPreview = smsPreview;
var detailsCompanyRole = User.FindFirst("CompanyRole")?.Value ?? string.Empty;
ViewBag.IsAdminOrManager = detailsCompanyRole is "CompanyAdmin" or "Administrator" or "Manager";
ViewBag.SmsEnabled = HttpContext.Items["AllowSms"] is true;
var jobPrefs = await GetCompanyPreferencesAsync(job.CompanyId);
if (guidedActivation == AppConstants.GuidedActivation.JobCreatedStep
&& jobPrefs?.FirstWorkflowCompleted == false)
@@ -2751,14 +2759,20 @@ public class JobsController : Controller
await _unitOfWork.CompleteAsync();
// Admin/Manager gets an SMS compose modal; ShopFloor workers trigger auto-send.
var companyRole = User.FindFirst("CompanyRole")?.Value ?? string.Empty;
var isAdminOrManager = companyRole is "CompanyAdmin" or "Administrator" or "Manager";
// Load job with customer for notification + SMS render
var jobForNotify = await _unitOfWork.Jobs.GetByIdAsync(dto.JobId, false, j => j.Customer);
// Notify customer that job is completed (only if user opted in)
if (dto.SendEmailToCustomer)
if (dto.SendEmailToCustomer && jobForNotify != null)
{
try
{
var jobForNotify = await _unitOfWork.Jobs.GetByIdAsync(dto.JobId, false, j => j.Customer);
if (jobForNotify != null)
await _notificationService.NotifyJobCompletedAsync(jobForNotify);
// Admin/Manager path: suppress auto-SMS so they can review via compose modal
await _notificationService.NotifyJobCompletedAsync(jobForNotify, suppressSms: isAdminOrManager);
}
catch (Exception ex)
{
@@ -2769,6 +2783,21 @@ public class JobsController : Controller
this.SetNotificationResultToast(completeNotifLog);
}
// For Admin/Manager: render the SMS template and store it for the compose modal
if (isAdminOrManager && jobForNotify != null)
{
try
{
var smsPreview = await _notificationService.RenderJobCompletedSmsAsync(jobForNotify);
if (smsPreview != null)
TempData["PendingSmsPreview"] = smsPreview;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "SMS render failed for job {Id}", dto.JobId);
}
}
await BroadcastJobUpdate(job.CompanyId, job.JobNumber!, job.Id, "Completed", "Job completed");
if (completedStatus != null)
@@ -2797,6 +2826,46 @@ public class JobsController : Controller
#endregion
#region SMS Compose
/// <summary>
/// Returns the pre-rendered job-completed SMS text so the Admin/Manager compose modal
/// can pre-fill the textarea when the "Send SMS" button is clicked from the job details page
/// (as opposed to the auto-populated TempData path from CompleteJob).
/// </summary>
[HttpGet]
public async Task<IActionResult> RenderJobSms(int jobId)
{
var job = await _unitOfWork.Jobs.GetByIdAsync(jobId, false, j => j.Customer);
if (job == null) return NotFound();
var text = await _notificationService.RenderJobCompletedSmsAsync(job);
if (text == null)
return Json(new { eligible = false, reason = "SMS is not enabled or customer has not opted in." });
return Json(new { eligible = true, message = text });
}
/// <summary>
/// Sends a manually-composed SMS for a job. Validates and auto-appends STOP language,
/// sends via Twilio, and writes a NotificationLog entry.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SendJobSms([FromBody] SendJobSmsRequest request)
{
if (request.JobId <= 0 || string.IsNullOrWhiteSpace(request.Message))
return Json(new { success = false, error = "Job ID and message are required." });
var job = await _unitOfWork.Jobs.GetByIdAsync(request.JobId, false, j => j.Customer);
if (job == null) return Json(new { success = false, error = "Job not found." });
var (success, error) = await _notificationService.SendJobSmsAsync(job, request.Message.Trim());
return Json(new { success, error });
}
#endregion
#region Edit Job Items (Wizard)
/// <summary>
@@ -64,6 +64,7 @@ public class PlatformSubscriptionController : Controller
AllowAiPhotoQuotes = c.AllowAiPhotoQuotes,
AllowAiInventoryAssist = c.AllowAiInventoryAssist,
AllowAiCatalogPriceCheck = c.AllowAiCatalogPriceCheck,
AllowSms = c.AllowSms,
IsActive = c.IsActive,
SortOrder = c.SortOrder
}).ToList();
@@ -104,6 +105,7 @@ public class PlatformSubscriptionController : Controller
AllowAiPhotoQuotes = config.AllowAiPhotoQuotes,
AllowAiInventoryAssist = config.AllowAiInventoryAssist,
AllowAiCatalogPriceCheck = config.AllowAiCatalogPriceCheck,
AllowSms = config.AllowSms,
IsActive = config.IsActive
};
@@ -149,6 +151,7 @@ public class PlatformSubscriptionController : Controller
config.AllowAiPhotoQuotes = dto.AllowAiPhotoQuotes;
config.AllowAiInventoryAssist = dto.AllowAiInventoryAssist;
config.AllowAiCatalogPriceCheck = dto.AllowAiCatalogPriceCheck;
config.AllowSms = dto.AllowSms;
config.IsActive = dto.IsActive;
await _unitOfWork.SubscriptionPlanConfigs.UpdateAsync(config);
@@ -3291,6 +3291,68 @@ public class QuotesController : Controller
}
}
/// <summary>
/// Sends the quote approval link to the customer via SMS.
/// Reuses the existing approval token when valid; generates a new one only when none exists or it is expired.
/// Does NOT regenerate a live token — so a previously emailed link stays valid.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SendQuoteApprovalSms(int id)
{
try
{
var quote = await _unitOfWork.Quotes.GetByIdAsync(id, false, q => q.Customer, q => q.QuoteStatus);
if (quote == null)
return Json(new { success = false, message = "Quote not found." });
// Determine recipient phone for the feedback message
string? recipientPhone = quote.CustomerId.HasValue
? (quote.Customer?.MobilePhone ?? quote.Customer?.Phone)
: quote.ProspectPhone;
string recipientName = quote.CustomerId.HasValue && quote.Customer != null
? (!string.IsNullOrWhiteSpace(quote.Customer.CompanyName)
? quote.Customer.CompanyName
: $"{quote.Customer.ContactFirstName} {quote.Customer.ContactLastName}".Trim())
: (!string.IsNullOrWhiteSpace(quote.ProspectContactName) ? quote.ProspectContactName
: quote.ProspectCompanyName ?? "Prospect");
// Ensure a valid (non-expired) approval token exists — generate only if missing or expired
bool tokenChanged = false;
if (string.IsNullOrEmpty(quote.ApprovalToken) ||
(quote.ApprovalTokenExpiresAt.HasValue && quote.ApprovalTokenExpiresAt.Value < DateTime.UtcNow))
{
var tokenBytes = System.Security.Cryptography.RandomNumberGenerator.GetBytes(32);
quote.ApprovalToken = Convert.ToBase64String(tokenBytes)
.Replace('+', '-').Replace('/', '_').TrimEnd('=');
quote.ApprovalTokenExpiresAt = DateTime.UtcNow.AddDays(
int.TryParse(await _platformSettings.GetAsync(PlatformSettingKeys.QuoteApprovalTokenDays), out var td) ? td : 30);
quote.ApprovalTokenUsedAt = null;
tokenChanged = true;
}
if (tokenChanged)
{
await _unitOfWork.Quotes.UpdateAsync(quote);
await _unitOfWork.CompleteAsync();
}
var (success, error) = await _notificationService.NotifyQuoteSentSmsAsync(quote);
if (!success)
return Json(new { success = false, message = error ?? "SMS could not be sent." });
var phone = string.IsNullOrWhiteSpace(recipientPhone) ? "their phone" : recipientPhone;
return Json(new { success = true, message = $"Approval link sent to {recipientName} via SMS ({phone})." });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error sending quote approval SMS for quote {QuoteId}", id);
return Json(new { success = false, message = "An unexpected error occurred. Please try again." });
}
}
/// <summary>
/// Returns the notification delivery history for a quote as a JSON array.
/// Used by the "Notifications Sent" tab on the Details page to show which emails/SMS
@@ -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; } = [];
}
@@ -1057,27 +1057,48 @@ public static class HelpKnowledgeBase
**What's New:** [/ReleaseNotes](/ReleaseNotes) A changelog of platform updates. Accessible from the user menu (top-right "What's New"). When the platform team publishes a new release note, all users receive a bell notification linking directly to this page.
### Email & SMS Notifications
The system also sends email and SMS notifications for key events:
The system sends email and SMS notifications for key events:
- Quote sent to customer
- Quote approved/rejected by customer
- Job status changes
- Job ready for pickup
- Job completed (email + SMS)
- Invoice sent
- Payment received
- Overdue payment reminders
Configure which notifications are enabled at [Company Settings](/CompanySettings) Notifications tab.
Email/SMS notification history: [/NotificationLogs](/NotificationLogs) under Settings/Admin
Email/SMS notification history: [/NotificationLogs](/NotificationLogs)
In-app notification history: [/InAppNotifications](/InAppNotifications)
### SMS Platform Setting
SMS/text-message features are controlled by a platform-level toggle in Platform Settings (SuperAdmin only at [/PlatformSettings](/PlatformSettings), in the Features group). When SMS is **off** (the default until Twilio is configured):
- SMS consent fields are hidden on all customer records
- SMS notification templates are hidden from Company Settings Notification Templates
- SMS channel filter is hidden in the Notification Log
- No outbound SMS messages are sent via Twilio
When SMS is turned **on**, all of the above become visible and active. Turning it back off hides everything again without deleting any stored phone numbers or SMS settings the data is preserved. Only a SuperAdmin can toggle this setting.
### SMS Notifications
SMS text messages are an opt-in feature at both the company level and the customer level.
**Enabling SMS for your company:**
Go to Company Settings Notifications tab SMS Notifications. Toggle "Enable SMS notifications" on.
The first time you enable it you will be asked to agree to the SMS terms of service, which covers your obligation to obtain prior written consent from each customer before texting them (required by the FCC/TCPA). Once agreed, you can toggle SMS on and off freely without re-agreeing unless the terms are updated.
**Enabling SMS per customer:**
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:**
- 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.
**Auto-send (Shop Floor workers):**
When a Shop Floor worker marks a job complete, the SMS is sent immediately using the template no compose step.
**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.
### 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.
@@ -1,4 +1,5 @@
using System.Security.Claims;
using Microsoft.Extensions.Caching.Memory;
using PowderCoating.Web.Services;
namespace PowderCoating.Web.Middleware;
@@ -10,22 +11,18 @@ namespace PowderCoating.Web.Middleware;
public class OnlineUserMiddleware
{
private readonly RequestDelegate _next;
/// <summary>
/// Maps each user ID to the UTC time the tracker was last updated.
/// A <see cref="System.Collections.Concurrent.ConcurrentDictionary{TKey,TValue}"/>
/// is used because multiple concurrent requests from the same user (e.g. polling
/// and a page navigation) may race to update the entry. The dictionary is
/// static so the throttle window persists across DI scope lifetimes — the
/// middleware instance itself may be recreated but the throttle state must not.
/// </summary>
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, DateTime> _throttle = new();
private readonly IMemoryCache _cache;
/// <summary>
/// Initialises the middleware with the next request delegate in the pipeline.
/// </summary>
/// <param name="next">The next middleware component.</param>
public OnlineUserMiddleware(RequestDelegate next) => _next = next;
/// <param name="cache">Shared app cache used for expiring per-user throttle entries.</param>
public OnlineUserMiddleware(RequestDelegate next, IMemoryCache cache)
{
_next = next;
_cache = cache;
}
/// <summary>
/// Calls the downstream pipeline first, then — after the response is
@@ -63,12 +60,15 @@ public class OnlineUserMiddleware
var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId)) return;
// Throttle: only update the tracker once per 60 seconds per user
// Throttle: only update the tracker once per 60 seconds per user.
// IMemoryCache automatically expires old entries so the throttle state
// does not grow without bound across the lifetime of the process.
var now = DateTime.UtcNow;
if (_throttle.TryGetValue(userId, out var lastWrite) && (now - lastWrite).TotalSeconds < 60)
var throttleKey = $"online-user-touch:{userId}";
if (_cache.TryGetValue(throttleKey, out _))
return;
_throttle[userId] = now;
_cache.Set(throttleKey, true, TimeSpan.FromSeconds(60));
var email = context.User.FindFirstValue(ClaimTypes.Email) ?? string.Empty;
var firstName = context.User.FindFirstValue(ClaimTypes.GivenName) ?? string.Empty;
@@ -91,6 +91,7 @@ public class SubscriptionMiddleware
{
context.Items["AllowOnlinePayments"] = true;
context.Items["AllowAccounting"] = true;
context.Items["AllowSms"] = true;
await _next(context);
return;
}
@@ -137,6 +138,9 @@ public class SubscriptionMiddleware
|| (company.OnlinePaymentsOverride ?? (planConfig?.AllowOnlinePayments ?? false));
context.Items["AllowAccounting"] = company.IsComped
|| (company.AccountingOverride ?? (planConfig?.AllowAccounting ?? false));
// SMS: comped gets it; admin force-disable beats everything else; then plan; then company opt-in
context.Items["AllowSms"] = company.IsComped
|| (!company.SmsDisabledByAdmin && (planConfig?.AllowSms ?? false) && company.SmsEnabled);
if (company.IsComped)
{
@@ -149,6 +149,16 @@
</div>
</div>
<h5 class="card-title mb-3 pb-2 border-bottom">SMS Override</h5>
<p class="text-muted small mb-3">Use this to immediately cut off SMS for a company — for example if they are sending abusive messages or have a billing dispute. This overrides the plan entitlement and the company's own opt-in setting.</p>
<div class="mb-4">
<div class="form-check form-switch">
<input asp-for="SmsDisabledByAdmin" class="form-check-input" type="checkbox" role="switch" id="SmsDisabledByAdmin" />
<label asp-for="SmsDisabledByAdmin" class="form-check-label fw-medium text-danger">Force-disable SMS for this company</label>
</div>
<div class="form-text">When checked, no outbound SMS will be sent for this company regardless of their plan or own settings.</div>
</div>
<div class="d-flex gap-2 justify-content-end">
<a asp-action="Index" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">
@@ -1079,6 +1079,45 @@
</a>
</h5>
<p class="text-muted">Control which events trigger email notifications and alert thresholds.</p>
@if (ViewBag.SmsEnabled == true)
{
<div class="card border mb-4">
<div class="card-body">
<h6 class="card-title fw-semibold"><i class="bi bi-phone me-1"></i> SMS Notifications</h6>
@if (Model.SmsDisabledByAdmin)
{
<div class="alert alert-danger alert-permanent mb-3 py-2">
<i class="bi bi-slash-circle me-1"></i>
<strong>SMS has been disabled by an administrator.</strong> Contact support to re-enable.
</div>
}
else if (!Model.AllowSms)
{
<div class="alert alert-info alert-permanent mb-3 py-2">
<i class="bi bi-info-circle me-1"></i>
SMS notifications are not included in your current plan. Upgrade to Pro or Enterprise to enable customer SMS alerts.
</div>
}
else
{
<p class="text-muted small mb-3">When enabled, customers who have given SMS consent will receive text alerts for job status changes (e.g. ready for pickup).</p>
<div class="form-check form-switch" id="smsToggleWrap">
<input class="form-check-input" type="checkbox" id="smsEnabledToggle" @(Model.SmsEnabled ? "checked" : "")
data-has-agreement="@(Model.HasCurrentSmsAgreement ? "true" : "false")"
data-terms-version="@Model.SmsTermsVersion">
<label class="form-check-label fw-medium" for="smsEnabledToggle">Enable SMS Notifications</label>
</div>
@if (!Model.HasCurrentSmsAgreement && !Model.SmsEnabled)
{
<div class="form-text text-warning mt-1">
<i class="bi bi-info-circle me-1"></i>You'll need to accept the SMS terms of service the first time you enable this.
</div>
}
}
</div>
</div>
}
<form id="notificationsForm">
<h6 class="border-bottom pb-2 mb-3">Email Sender</h6>
<div class="row mb-3">
@@ -1995,6 +2034,54 @@
</div>
</div>
<!-- SMS Terms of Service Agreement Modal -->
<div class="modal fade" id="smsTermsModal" tabindex="-1" aria-labelledby="smsTermsModalLabel" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title" id="smsTermsModalLabel">
<i class="bi bi-phone me-2"></i>SMS Notifications — Terms of Service
</h5>
</div>
<div class="modal-body">
<div class="alert alert-warning alert-permanent mb-3">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
<strong>Read carefully before enabling.</strong> Sending unsolicited text messages carries significant legal risk. By enabling SMS you are personally accepting responsibility for your company's compliance.
</div>
<h6 class="fw-bold">1. Prior Express Written Consent Required</h6>
<p class="text-muted small">You <strong>must obtain clear, documented consent</strong> from each customer before sending them any SMS message. This means each customer must have explicitly agreed — in writing or through a recorded digital interaction — that they wish to receive text messages from your business. Enabling this feature is not consent on their behalf. You must collect and record their authorization individually, before enabling SMS for their account in this system.</p>
<h6 class="fw-bold">2. Federal Law Governs SMS — Fines Are Real</h6>
<p class="text-muted small">The <strong>Telephone Consumer Protection Act (TCPA)</strong>, enforced by the Federal Communications Commission (FCC), imposes fines of <strong>$500 to $1,500 per individual message</strong> sent without proper authorization. These fines apply per text, not per customer. A single campaign to 100 unconsented recipients could result in exposure of $50,000 to $150,000. The FCC and private plaintiffs both actively pursue TCPA violations.</p>
<h6 class="fw-bold">3. Opt-Out Requests Must Be Honored Immediately</h6>
<p class="text-muted small">Any customer who replies <strong>STOP, UNSUBSCRIBE, CANCEL, END, or QUIT</strong> must be removed from all future SMS immediately. This system will process inbound opt-out replies automatically, but you must also honor any opt-out communicated by phone, email, or in person. Continuing to text a customer after an opt-out is a TCPA violation.</p>
<h6 class="fw-bold">4. Message Rates &amp; Content Restrictions</h6>
<p class="text-muted small">Every message sent must include your business name and an opt-out reminder (e.g., "Reply STOP to opt out"). Messages must be directly relevant to the service the customer consented to receive and must not contain solicitations, promotions, or third-party offers unless the customer has separately consented to those.</p>
<h6 class="fw-bold">5. Your Responsibility — Not Ours</h6>
<p class="text-muted small">Powder Coating Logix provides this feature as a communication tool only. <strong>We are not responsible for how you use it.</strong> You agree that your company is solely responsible for obtaining proper consent, maintaining records of that consent, honoring opt-outs, and ensuring all outbound messages comply with the TCPA, FCC regulations, and any applicable state laws. You agree to indemnify and hold Powder Coating Logix harmless from any claims, fines, or damages arising from your company's use of SMS.</p>
<hr />
<div class="form-check mt-3">
<input class="form-check-input" type="checkbox" id="smsTermsAgreementCheck">
<label class="form-check-label fw-semibold" for="smsTermsAgreementCheck">
I have read and understood the above terms. I confirm that my company will obtain proper customer consent before enabling SMS for any individual customer, and I accept full responsibility for our compliance with all applicable laws.
</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="smsTermsDeclineBtn">Cancel — Keep SMS Disabled</button>
<button type="button" class="btn btn-primary" id="smsTermsAcceptBtn" disabled>
<i class="bi bi-check-circle me-1"></i>I Agree &amp; Enable SMS
</button>
</div>
</div>
</div>
</div>
@section Scripts {
<script>
$(document).ready(function () {
@@ -2415,6 +2502,83 @@
};
}, 'Save Retention Policy');
// SMS toggle — shows terms modal on first enable (or after terms version change)
(function () {
const toggle = document.getElementById('smsEnabledToggle');
if (!toggle) return;
const smsTermsModal = new bootstrap.Modal(document.getElementById('smsTermsModal'));
const acceptBtn = document.getElementById('smsTermsAcceptBtn');
const declineBtn = document.getElementById('smsTermsDeclineBtn');
const agreeCheck = document.getElementById('smsTermsAgreementCheck');
// Unlock the accept button only when checkbox is ticked
agreeCheck.addEventListener('change', function () {
acceptBtn.disabled = !this.checked;
});
function postSmsPreference(enabled, agreedToTerms, termsVersion) {
$.ajax({
url: '@Url.Action("UpdateSmsPreferences", "CompanySettings")',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ SmsEnabled: enabled, AgreedToTerms: agreedToTerms, TermsVersion: termsVersion }),
headers: { 'RequestVerificationToken': $('input[name="__RequestVerificationToken"]').first().val() },
success: function (res) {
if (res.success) {
toggle.dataset.hasAgreement = 'true';
showToast('success', res.message);
} else {
// Revert toggle on failure
toggle.checked = !enabled;
showToast('error', res.message || 'Failed to save SMS preference.');
}
},
error: function () {
toggle.checked = !enabled;
showToast('error', 'Failed to save SMS preference.');
}
});
}
toggle.addEventListener('change', function () {
const enabled = this.checked;
const hasAgreement = this.dataset.hasAgreement === 'true';
const termsVersion = this.dataset.termsVersion;
if (!enabled) {
// Disabling: no agreement needed
postSmsPreference(false, false, null);
return;
}
if (hasAgreement) {
// Re-enabling: already agreed to this version
postSmsPreference(true, false, null);
return;
}
// First enable (or terms version changed): show the modal
agreeCheck.checked = false;
acceptBtn.disabled = true;
smsTermsModal.show();
// Revert toggle until they explicitly agree
toggle.checked = false;
acceptBtn.onclick = function () {
smsTermsModal.hide();
toggle.checked = true;
postSmsPreference(true, true, termsVersion);
};
declineBtn.onclick = function () {
smsTermsModal.hide();
toggle.checked = false;
};
});
})();
// Toast helper function (exposed globally for lookup management)
window.showToast = function(type, message) {
if (type === 'success') {
@@ -89,6 +89,8 @@
<li><strong>Contact Name</strong> — the person you deal with day to day.</li>
<li><strong>Email</strong> — used for quote and invoice notifications.</li>
<li><strong>Phone</strong> — primary contact number.</li>
<li><strong>Mobile Phone</strong> — used for SMS notifications. Required if you want to text this customer.</li>
<li><strong>SMS Opt-In</strong> — check this only after you have obtained the customer's consent to receive text messages. Visible only when SMS is enabled for your company.</li>
<li><strong>Address</strong> — billing and shipping address fields.</li>
</ul>
</li>
+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">
@@ -361,7 +361,7 @@
<li class="mb-2">Each reminder sent is recorded in the notification log, visible via <strong>Settings &rsaquo; Notification Templates &amp; Logs</strong>.</li>
</ol>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<div class="alert alert-permanent alert-info d-flex gap-2 mb-3" role="alert">
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
<div>
Payment reminders are sent once per threshold per invoice — if you have <code>7,14,30</code>
@@ -370,6 +370,55 @@
receive any further reminders (its status changes to Paid and it is excluded from future checks).
</div>
</div>
<h3 class="h6 fw-semibold mt-3 mb-2">SMS Notifications</h3>
<p>
SMS text messages are an opt-in feature at both the company level and the customer level.
When SMS is not available on your plan the toggle will not appear.
</p>
<ul class="mb-3">
<li class="mb-2">
<strong>Enable SMS Notifications</strong> — master company toggle. The first time you enable it
you must agree to the SMS terms of service, which covers your obligation to obtain prior written
customer consent before texting them (required by FCC/TCPA regulations). Once agreed, you can
toggle SMS on and off freely without re-agreeing unless the terms are updated.
</li>
<li class="mb-2">
<strong>Per-customer opt-in</strong> — even with SMS enabled here, a customer will only receive
texts if their record has a <strong>Mobile Phone</strong> number and the <strong>SMS Opt-In</strong>
box checked. You are responsible for obtaining each customer's consent before enabling this on
their record.
</li>
</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>Job Completed</strong> — notifies the customer their job is done and ready for pickup.</li>
<li class="mb-1">
<strong>Quote Approval Request</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>
</ul>
<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
SMS from your notification template and opens a compose window so you can personalize the message
before it sends. A <strong>Send SMS</strong> button on the job details page lets you send a follow-up
at any time.
</p>
<p>
When a <strong>Shop Floor</strong> worker marks a job complete the SMS is sent automatically using
the template — no compose step.
</p>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
<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.
</div>
</div>
</section>
<section id="named-ovens" class="mb-5">
@@ -1346,6 +1346,14 @@
<i class="bi bi-check-circle me-2"></i>Complete Job
</button>
}
@if ((bool)(ViewBag.IsAdminOrManager ?? false) && (bool)(ViewBag.SmsEnabled ?? false) && Model.CustomerNotifyBySms && !string.IsNullOrWhiteSpace(Model.CustomerMobilePhone))
{
<button type="button" class="btn btn-outline-info" id="btnSendSms"
data-job-id="@Model.Id"
title="Send a custom SMS to @Model.CustomerName">
<i class="bi bi-chat-dots me-2"></i>Send SMS
</button>
}
<a asp-action="Delete" asp-route-id="@Model.Id" class="btn btn-outline-danger">
<i class="bi bi-trash me-2"></i>Delete Job
</a>
@@ -1819,6 +1827,51 @@
</div>
</div>
<!-- SMS Compose Modal (Admin/Manager only) -->
@if ((bool)(ViewBag.IsAdminOrManager ?? false) && (bool)(ViewBag.SmsEnabled ?? false))
{
<div class="modal fade" id="smsComposeModal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header bg-info bg-opacity-10">
<h5 class="modal-title">
<i class="bi bi-chat-dots me-2 text-info"></i>Send SMS to @Model.CustomerName
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" id="smsModalClose"></button>
</div>
<div class="modal-body">
@if (!string.IsNullOrWhiteSpace(Model.CustomerMobilePhone))
{
<p class="text-muted small mb-3">
<i class="bi bi-phone me-1"></i>Sending to: <strong>@Model.CustomerMobilePhone</strong>
</p>
}
<div class="mb-2">
<label class="form-label fw-semibold" for="smsMessageText">Message</label>
<textarea class="form-control" id="smsMessageText" rows="5"
placeholder="Type your message…" maxlength="160"></textarea>
<div class="d-flex justify-content-between mt-1">
<div id="smsStopWarning" class="text-warning small d-none">
<i class="bi bi-exclamation-triangle me-1"></i>"Reply STOP to opt out." will be appended automatically.
</div>
<div class="ms-auto text-muted small"><span id="smsCharCount">0</span> / 160</div>
</div>
</div>
<div id="smsSendError" class="alert alert-danger d-none mt-2"></div>
</div>
<div class="modal-footer justify-content-between">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal" id="smsDismissBtn">
Skip — don't send
</button>
<button type="button" class="btn btn-info text-white" id="smsSendBtn">
<i class="bi bi-send me-1"></i>Send SMS
</button>
</div>
</div>
</div>
</div>
}
<!-- Hidden form used by item-wizard.js to collect item data and submit to UpdateItems -->
<form asp-action="UpdateItems" asp-controller="Jobs" method="post" id="jobItemsForm" style="display:none">
@Html.AntiForgeryToken()
@@ -2967,6 +3020,23 @@
});
})();
</script>
@if ((bool)(ViewBag.IsAdminOrManager ?? false) && (bool)(ViewBag.SmsEnabled ?? false))
{
<script src="~/js/jobs-sms-compose.js" asp-append-version="true"></script>
<script>
(() => {
const pendingPreview = @Html.Raw(ViewBag.PendingSmsPreview != null
? System.Text.Json.JsonSerializer.Serialize((string)ViewBag.PendingSmsPreview)
: "null");
const jobIdForSms = @Model.Id;
const renderUrl = '@Url.Action("RenderJobSms", "Jobs")';
const sendUrl = '@Url.Action("SendJobSms", "Jobs")';
const customerOptedIn = @(Model.CustomerNotifyBySms ? "true" : "false");
window.__smsCompose = { pendingPreview, jobIdForSms, renderUrl, sendUrl, customerOptedIn };
})();
</script>
}
}
<!-- Save as Template Modal -->
@@ -132,6 +132,19 @@
</div>
</div>
<h5 class="mb-3 pb-2 border-bottom mt-4">SMS Notifications</h5>
<div class="mb-4">
<div class="form-check form-switch">
<input asp-for="AllowSms" class="form-check-input" type="checkbox" role="switch" />
<label asp-for="AllowSms" class="form-check-label fw-medium">Allow SMS Notifications</label>
</div>
<div class="form-text">
When enabled, companies on this plan can send SMS job-status notifications to customers
(subject to the platform SMS kill-switch and the company's own opt-in setting).
</div>
</div>
<h5 class="mb-3 pb-2 border-bottom mt-4">AI Features</h5>
<div class="mb-3">
@@ -169,6 +169,19 @@
}
</td>
</tr>
<tr>
<td class="text-muted">SMS Notifications</td>
<td>
@if (plan.AllowSms)
{
<span class="badge bg-success">Enabled</span>
}
else
{
<span class="badge bg-secondary">Disabled</span>
}
</td>
</tr>
<tr class="table-light">
<td colspan="2" class="fw-semibold small text-uppercase text-muted py-1">Stripe</td>
</tr>
@@ -1478,7 +1478,10 @@
</form>
}
<button type="button" class="btn btn-outline-primary" onclick="resendQuote(@Model.Id)">
<i class="bi bi-envelope-arrow-up me-1"></i>Send Quote to Customer
<i class="bi bi-envelope-arrow-up me-1"></i>Send Quote via Email
</button>
<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 (!Model.ConvertedToJobId.HasValue)
{
@@ -2012,6 +2015,33 @@
</style>
}
<!-- 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">
<div class="modal-content">
<div class="modal-header" id="sendQuoteSmsModalHeader">
<h5 class="modal-title" id="sendQuoteSmsModalLabel">
<i class="bi bi-chat-dots me-2"></i>Send Quote via SMS
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-center" id="sendQuoteSmsBody">
<div id="sendQuoteSmsSending">
<div class="spinner-border text-info mb-3" role="status"></div>
<div class="text-muted">Sending SMS&hellip;</div>
</div>
<div id="sendQuoteSmsResult" class="d-none">
<i id="sendQuoteSmsIcon" class="fs-1 d-block mb-3"></i>
<p id="sendQuoteSmsMessage" class="mb-0"></p>
</div>
</div>
<div class="modal-footer d-none" id="sendQuoteSmsFooter">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- Send Quote Modal -->
<div class="modal fade" id="sendQuoteModal" tabindex="-1" aria-labelledby="sendQuoteModalLabel" aria-hidden="true">
<div class="modal-dialog modal-sm">
@@ -2131,6 +2161,53 @@
});
}
function sendQuoteSms(quoteId) {
document.getElementById('sendQuoteSmsSending').classList.remove('d-none');
document.getElementById('sendQuoteSmsResult').classList.add('d-none');
document.getElementById('sendQuoteSmsFooter').classList.add('d-none');
document.getElementById('sendQuoteSmsModalHeader').className = 'modal-header';
const modal = new bootstrap.Modal(document.getElementById('sendQuoteSmsModal'));
modal.show();
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
fetch('@Url.Action("SendQuoteApprovalSms", "Quotes")?id=' + quoteId, {
method: 'POST',
headers: { 'RequestVerificationToken': token, 'X-Requested-With': 'XMLHttpRequest' }
})
.then(r => r.json())
.then(data => {
document.getElementById('sendQuoteSmsSending').classList.add('d-none');
document.getElementById('sendQuoteSmsResult').classList.remove('d-none');
document.getElementById('sendQuoteSmsFooter').classList.remove('d-none');
const icon = document.getElementById('sendQuoteSmsIcon');
const msg = document.getElementById('sendQuoteSmsMessage');
const header = document.getElementById('sendQuoteSmsModalHeader');
if (data.success) {
icon.className = 'bi bi-check-circle-fill text-success fs-1 d-block mb-3';
header.className = 'modal-header bg-success text-white';
showInfo(data.message, 'SMS Sent');
} else {
icon.className = 'bi bi-x-circle-fill text-danger fs-1 d-block mb-3';
header.className = 'modal-header bg-danger text-white';
showWarning(data.message, 'SMS Not Sent');
}
msg.textContent = data.message;
})
.catch(() => {
document.getElementById('sendQuoteSmsSending').classList.add('d-none');
document.getElementById('sendQuoteSmsResult').classList.remove('d-none');
document.getElementById('sendQuoteSmsFooter').classList.remove('d-none');
document.getElementById('sendQuoteSmsIcon').className = 'bi bi-x-circle-fill text-danger fs-1 d-block mb-3';
document.getElementById('sendQuoteSmsModalHeader').className = 'modal-header bg-danger text-white';
document.getElementById('sendQuoteSmsMessage').textContent = 'A network error occurred. Please try again.';
showWarning('A network error occurred. Please try again.', 'SMS Not Sent');
});
}
function loadNotifications(quoteId) {
const modal = new bootstrap.Modal(document.getElementById('notificationsModal'));
document.getElementById('notificationsLoading').classList.remove('d-none');
@@ -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>
}
+4 -3
View File
@@ -48,9 +48,10 @@
"FromName": "Powder Coating App Staff"
},
"Twilio": {
"AccountSid": "your-account-sid-here",
"AuthToken": "your-auth-token-here",
"FromNumber": "+1XXXXXXXXXX"
"AccountSid": "your-twilio-account-sid",
"AuthToken": "your-twilio-auth-token",
"FromNumber": "your-twilio-from-number",
"DevRedirectPhone": ""
},
"Stripe": {
"SecretKey": "sk_test_51TM6ukDedBup3CZU5TLbLhbBZDuBYwwygoc58JZwIrYQ2SyhKPUFcbHkFV1Q5osqkIEPt3XkwgsuinSJbY58GLx300l7z2lvqg",
@@ -0,0 +1,128 @@
/**
* SMS compose modal for Job Details Admin/Manager path.
*
* Entry points:
* - Auto-opens after CompleteJob when TempData contains a pending preview.
* - Opened manually via the "Send SMS" button; fetches a fresh template first.
*
* Requires window.__smsCompose to be set by the inline Razor script before this file loads.
*/
(function () {
'use strict';
const MODAL_ID = 'smsComposeModal';
const TEXTAREA = 'smsMessageText';
const CHAR_COUNT = 'smsCharCount';
const STOP_WARN = 'smsStopWarning';
const SEND_BTN = 'smsSendBtn';
const ERROR_DIV = 'smsSendError';
const MAX_CHARS = 160;
const STOP_TOKEN = 'STOP';
function antiForgeryToken() {
return document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
}
function el(id) { return document.getElementById(id); }
function openModal(prefilledText) {
const modal = bootstrap.Modal.getOrCreateInstance(el(MODAL_ID));
const textarea = el(TEXTAREA);
if (textarea) textarea.value = prefilledText ?? '';
updateCharCount();
el(ERROR_DIV)?.classList.add('d-none');
modal.show();
}
function updateCharCount() {
const textarea = el(TEXTAREA);
if (!textarea) return;
const text = textarea.value;
const len = text.length;
const charEl = el(CHAR_COUNT);
const warnEl = el(STOP_WARN);
if (charEl) charEl.textContent = len;
if (warnEl) {
const hasStop = text.toUpperCase().includes(STOP_TOKEN);
warnEl.classList.toggle('d-none', hasStop);
}
}
function showError(msg) {
const div = el(ERROR_DIV);
if (!div) return;
div.textContent = msg;
div.classList.remove('d-none');
}
async function sendSms() {
const cfg = window.__smsCompose ?? {};
const btn = el(SEND_BTN);
let message = el(TEXTAREA)?.value?.trim() ?? '';
if (!message) { showError('Please enter a message.'); return; }
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Sending…'; }
el(ERROR_DIV)?.classList.add('d-none');
try {
const resp = await fetch(cfg.sendUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': antiForgeryToken()
},
body: JSON.stringify({ jobId: cfg.jobIdForSms, message })
});
const data = await resp.json();
if (data.success) {
bootstrap.Modal.getInstance(el(MODAL_ID))?.hide();
// Show a quick success toast using existing toastSuccess pattern if available
const toastEl = document.getElementById('successToast');
const toastMsg = document.getElementById('successToastMessage');
if (toastEl && toastMsg) {
toastMsg.textContent = 'SMS sent successfully.';
bootstrap.Toast.getOrCreateInstance(toastEl).show();
}
} else {
showError(data.error ?? 'Failed to send SMS. Please try again.');
}
} catch {
showError('A network error occurred. Please check your connection and try again.');
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="bi bi-send me-1"></i>Send SMS'; }
}
}
document.addEventListener('DOMContentLoaded', () => {
const cfg = window.__smsCompose ?? {};
// Character counter
el(TEXTAREA)?.addEventListener('input', updateCharCount);
// Send button
el(SEND_BTN)?.addEventListener('click', sendSms);
// "Send SMS" button on the job details action bar
document.getElementById('btnSendSms')?.addEventListener('click', async () => {
if (!cfg.customerOptedIn) {
alert('This customer has not opted in to SMS notifications.');
return;
}
// Fetch a fresh render of the template
try {
const resp = await fetch(`${cfg.renderUrl}?jobId=${cfg.jobIdForSms}`);
const data = await resp.json();
openModal(data.eligible ? data.message : '');
} catch {
openModal('');
}
});
// Auto-open after CompleteJob if server passed a pending preview
if (cfg.pendingPreview) {
// Small delay so the page has fully rendered
setTimeout(() => openModal(cfg.pendingPreview), 400);
}
});
})();
+5 -1
View File
@@ -256,7 +256,11 @@ document.addEventListener('DOMContentLoaded', async () => {
const result = await loginWithPasskey();
if (result.success) {
window.location.href = result.redirectUrl || '/';
// Prefer the login page's ReturnUrl hidden field (set by the server to route
// through EnrollPrompt with the original destination) over the server's default
// dashboard redirect, so QR-code and deep-link flows land in the right place.
const formReturnUrl = document.querySelector('input[name="ReturnUrl"]')?.value;
window.location.href = formReturnUrl || result.redirectUrl || '/';
} else if (!result.cancelled) {
passkeyBtn.disabled = false;
passkeyBtn.innerHTML = `<i class="bi bi-fingerprint"></i> ${label}`;