diff --git a/src/PowderCoating.Application/DTOs/Company/CompanyDtos.cs b/src/PowderCoating.Application/DTOs/Company/CompanyDtos.cs
index 29f3213..2e25b2c 100644
--- a/src/PowderCoating.Application/DTOs/Company/CompanyDtos.cs
+++ b/src/PowderCoating.Application/DTOs/Company/CompanyDtos.cs
@@ -165,6 +165,9 @@ public class UpdateCompanyDto
public bool? OnlinePaymentsOverride { get; set; }
public bool? AccountingOverride { get; set; }
+ /// When true, SuperAdmin has force-disabled SMS for this company regardless of plan or company settings.
+ public bool SmsDisabledByAdmin { get; set; }
+
public string? TimeZone { get; set; }
}
diff --git a/src/PowderCoating.Application/DTOs/Company/CompanySettingsDtos.cs b/src/PowderCoating.Application/DTOs/Company/CompanySettingsDtos.cs
index 72b0f37..0611d5a 100644
--- a/src/PowderCoating.Application/DTOs/Company/CompanySettingsDtos.cs
+++ b/src/PowderCoating.Application/DTOs/Company/CompanySettingsDtos.cs
@@ -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; }
+ /// True when the company has an accepted agreement for the current SmsTermsVersion.
+ public bool HasCurrentSmsAgreement { get; set; }
+ public string SmsTermsVersion { get; set; } = string.Empty;
+ }
+
+ ///
+ /// 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 AppConstants.SmsTermsVersion.
+ ///
+ public class UpdateSmsPreferencesDto
+ {
+ public bool SmsEnabled { get; set; }
+ public bool AgreedToTerms { get; set; }
+ public string? TermsVersion { get; set; }
}
///
diff --git a/src/PowderCoating.Application/DTOs/Job/JobDtos.cs b/src/PowderCoating.Application/DTOs/Job/JobDtos.cs
index e95a0d9..b8b8196 100644
--- a/src/PowderCoating.Application/DTOs/Job/JobDtos.cs
+++ b/src/PowderCoating.Application/DTOs/Job/JobDtos.cs
@@ -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
{
diff --git a/src/PowderCoating.Application/DTOs/Subscription/SubscriptionPlanConfigDto.cs b/src/PowderCoating.Application/DTOs/Subscription/SubscriptionPlanConfigDto.cs
index 35d53e7..cc69d6f 100644
--- a/src/PowderCoating.Application/DTOs/Subscription/SubscriptionPlanConfigDto.cs
+++ b/src/PowderCoating.Application/DTOs/Subscription/SubscriptionPlanConfigDto.cs
@@ -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; }
}
diff --git a/src/PowderCoating.Application/Interfaces/INotificationService.cs b/src/PowderCoating.Application/Interfaces/INotificationService.cs
index b4e66e3..b5f691c 100644
--- a/src/PowderCoating.Application/Interfaces/INotificationService.cs
+++ b/src/PowderCoating.Application/Interfaces/INotificationService.cs
@@ -11,6 +11,13 @@ public interface INotificationService
///
Task NotifyQuoteSentAsync(Quote quote, byte[]? pdfAttachment = null, string? pdfFilename = null);
+ ///
+ /// 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.
+ ///
+ Task<(bool Success, string? Error)> NotifyQuoteSentSmsAsync(Quote quote);
+
///
/// Notify when a quote is approved by a customer.
///
@@ -23,8 +30,23 @@ public interface INotificationService
///
/// Notify customer when a job is completed and ready for pickup.
+ /// When is true the SMS is skipped so an admin can review
+ /// the message via before sending manually.
///
- Task NotifyJobCompletedAsync(Job job);
+ Task NotifyJobCompletedAsync(Job job, bool suppressSms = false);
+
+ ///
+ /// 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.
+ ///
+ Task RenderJobCompletedSmsAsync(Job job);
+
+ ///
+ /// 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).
+ ///
+ Task<(bool Success, string? Error)> SendJobSmsAsync(Job job, string message);
///
/// Sends a welcome/confirmation SMS after staff records verbal SMS consent.
diff --git a/src/PowderCoating.Application/Mappings/JobProfile.cs b/src/PowderCoating.Application/Mappings/JobProfile.cs
index 4da668b..2e206bb 100644
--- a/src/PowderCoating.Application/Mappings/JobProfile.cs
+++ b/src/PowderCoating.Application/Mappings/JobProfile.cs
@@ -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()
diff --git a/src/PowderCoating.Core/Entities/Company.cs b/src/PowderCoating.Core/Entities/Company.cs
index 6367462..b7c3193 100644
--- a/src/PowderCoating.Core/Entities/Company.cs
+++ b/src/PowderCoating.Core/Entities/Company.cs
@@ -88,6 +88,19 @@ public class Company : BaseEntity
///
public bool? AccountingOverride { get; set; }
+ ///
+ /// 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.
+ ///
+ public bool SmsEnabled { get; set; } = false;
+
+ ///
+ /// 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.
+ ///
+ 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");
diff --git a/src/PowderCoating.Core/Entities/CompanySmsAgreement.cs b/src/PowderCoating.Core/Entities/CompanySmsAgreement.cs
new file mode 100644
index 0000000..5cd417e
--- /dev/null
+++ b/src/PowderCoating.Core/Entities/CompanySmsAgreement.cs
@@ -0,0 +1,33 @@
+namespace PowderCoating.Core.Entities;
+
+///
+/// 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 matches
+/// AppConstants.SmsTermsVersion is the authoritative acceptance for that company.
+/// Never soft-deleted — this is a legal audit trail.
+///
+public class CompanySmsAgreement : BaseEntity
+{
+ /// The Identity user ID of the admin who clicked "I Agree".
+ public string AgreedByUserId { get; set; } = string.Empty;
+
+ /// Display name snapshot of the user at the time of agreement (for audit readability after user changes).
+ public string AgreedByUserName { get; set; } = string.Empty;
+
+ /// UTC timestamp of acceptance.
+ public DateTime AgreedAt { get; set; }
+
+ /// Client IP address at the time of acceptance. Stored for legal/fraud purposes.
+ public string? IpAddress { get; set; }
+
+ /// HTTP User-Agent header at the time of acceptance.
+ public string? UserAgent { get; set; }
+
+ ///
+ /// The version of the SMS terms that was accepted (matches AppConstants.SmsTermsVersion
+ /// at the moment of acceptance). When the platform bumps this version, existing records become
+ /// stale and the company must re-accept.
+ ///
+ public string TermsVersion { get; set; } = string.Empty;
+}
diff --git a/src/PowderCoating.Core/Entities/SubscriptionPlanConfig.cs b/src/PowderCoating.Core/Entities/SubscriptionPlanConfig.cs
index dd20d8f..4d46d07 100644
--- a/src/PowderCoating.Core/Entities/SubscriptionPlanConfig.cs
+++ b/src/PowderCoating.Core/Entities/SubscriptionPlanConfig.cs
@@ -49,6 +49,9 @@ public class SubscriptionPlanConfig : BaseEntity
/// When true, companies on this plan can run the AI Catalog Price Check (Enterprise only).
public bool AllowAiCatalogPriceCheck { get; set; } = false;
+ /// When true, companies on this plan can send SMS notifications to customers (subject to platform kill-switch and per-company opt-in).
+ public bool AllowSms { get; set; } = false;
+
public bool IsActive { get; set; } = true;
public int SortOrder { get; set; }
}
diff --git a/src/PowderCoating.Core/Interfaces/IUnitOfWork.cs b/src/PowderCoating.Core/Interfaces/IUnitOfWork.cs
index 1e5df77..1b9a593 100644
--- a/src/PowderCoating.Core/Interfaces/IUnitOfWork.cs
+++ b/src/PowderCoating.Core/Interfaces/IUnitOfWork.cs
@@ -9,6 +9,7 @@ public interface IUnitOfWork : IDisposable
IRepository Companies { get; }
IRepository CompanyOperatingCosts { get; }
IRepository CompanyPreferences { get; }
+ IRepository CompanySmsAgreements { get; }
// AI Predictions
IRepository AiItemPredictions { get; }
diff --git a/src/PowderCoating.Core/Interfaces/Services/IDashboardReadService.cs b/src/PowderCoating.Core/Interfaces/Services/IDashboardReadService.cs
index c9e0435..33d6124 100644
--- a/src/PowderCoating.Core/Interfaces/Services/IDashboardReadService.cs
+++ b/src/PowderCoating.Core/Interfaces/Services/IDashboardReadService.cs
@@ -1,30 +1,127 @@
using PowderCoating.Core.Entities;
+using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Interfaces.Services;
///
-/// 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.
///
public record DashboardIndexData(
- List ActiveJobs,
- decimal MonthlyRevenue,
+ int ActiveJobsCount,
+ int TodaysJobsCount,
+ List TodaysJobs,
+ int OverdueJobsCount,
+ List OverdueJobs,
+ List InProgressJobs,
+ int TodaysAppointmentsCount,
List TodaysAppointments,
+ int LowStockCount,
+ List LowStockItems,
+ int PendingMaintenanceCount,
List UpcomingMaintenance,
+ int PendingQuotesCount,
+ decimal PendingQuoteValue,
List PendingQuotes,
- List OpenInvoices,
+ List ExpiringQuotes,
+ int ActiveCustomersCount,
+ decimal MonthlyRevenue,
+ decimal OutstandingAr,
decimal InvoicedThisMonth,
decimal CollectedThisMonth,
+ int OverdueInvoicesCount,
+ decimal OverdueInvoicesAmount,
+ DashboardArAgingData ArAging,
+ List OverdueInvoices,
List RecentPayments,
List RecentQuotes,
List RecentJobs,
- List JobsNeedingPowder,
- List JobsWithOrderedPowder,
+ List EquipmentAlerts,
+ List PowderOrdersNeeded,
+ List PowderOrdersPlaced,
+ int BillsDueCount,
+ decimal BillsDueAmount,
List BillsDue,
string? TipOfTheDay
);
+///
+/// AR aging bucket totals used by the dashboard receivables summary.
+///
+public record DashboardArAgingData(
+ decimal Current,
+ decimal Days1To30,
+ decimal Days31To60,
+ decimal Days61To90,
+ decimal DaysOver90
+);
+
+///
+/// Flattened powder-order line data so the controller does not need to materialize full job/item/coat graphs.
+///
+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
+);
+
+///
+/// Aggregated data for the SuperAdmin dashboard.
+///
+public record SuperAdminDashboardData(
+ int TotalCompanies,
+ int ActiveCompanies,
+ int InactiveCompanies,
+ int TotalUsers,
+ int ActiveSubscriptions,
+ int GracePeriodCount,
+ int ExpiredCount,
+ Dictionary PlanDistribution,
+ List CompanyAlerts,
+ List 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
+);
+
///
/// 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
/// The local date used for date-range predicates (today, start-of-month, etc.).
Task GetIndexDataAsync(DateTime today);
+ /// Fetches all data needed to render the SuperAdmin dashboard.
+ Task GetSuperAdminDashboardDataAsync(DateTime today);
+
/// Returns the total count of tenant users (CompanyId > 0) for the SuperAdmin dashboard.
Task GetTotalUserCountAsync();
}
diff --git a/src/PowderCoating.Infrastructure/Data/ApplicationDbContext.cs b/src/PowderCoating.Infrastructure/Data/ApplicationDbContext.cs
index b6f8416..7b5a8dd 100644
--- a/src/PowderCoating.Infrastructure/Data/ApplicationDbContext.cs
+++ b/src/PowderCoating.Infrastructure/Data/ApplicationDbContext.cs
@@ -143,6 +143,9 @@ public class ApplicationDbContext : IdentityDbContext
/// Tenant company records. Soft-delete only filter applies (no CompanyId filter — SuperAdmin manages all companies).
public DbSet Companies { get; set; }
+ /// Immutable audit trail of SMS terms-of-service acceptances per company. Tenant-filtered by CompanyId; never soft-deleted.
+ public DbSet CompanySmsAgreements { get; set; }
+
/// AI quote-item predictions; tenant-filtered. Both QuoteItem and JobItem share a single prediction record via nullable FK (no duplication on quote→job conversion).
public DbSet AiItemPredictions { get; set; }
diff --git a/src/PowderCoating.Infrastructure/Data/SeedData.cs b/src/PowderCoating.Infrastructure/Data/SeedData.cs
index c25fc1c..f6ecccf 100644
--- a/src/PowderCoating.Infrastructure/Data/SeedData.cs
+++ b/src/PowderCoating.Infrastructure/Data/SeedData.cs
@@ -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,
diff --git a/src/PowderCoating.Infrastructure/Migrations/20260502000902_AddSmsGating.Designer.cs b/src/PowderCoating.Infrastructure/Migrations/20260502000902_AddSmsGating.Designer.cs
new file mode 100644
index 0000000..4d9a1de
--- /dev/null
+++ b/src/PowderCoating.Infrastructure/Migrations/20260502000902_AddSmsGating.Designer.cs
@@ -0,0 +1,9340 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using PowderCoating.Infrastructure.Data;
+
+#nullable disable
+
+namespace PowderCoating.Infrastructure.Migrations
+{
+ [DbContext(typeof(ApplicationDbContext))]
+ [Migration("20260502000902_AddSmsGating")]
+ partial class AddSmsGating
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.11")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Name")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("NormalizedName")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique()
+ .HasDatabaseName("RoleNameIndex")
+ .HasFilter("[NormalizedName] IS NOT NULL");
+
+ b.ToTable("AspNetRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ClaimValue")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("RoleId")
+ .IsRequired()
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetRoleClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ClaimValue")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.Property("LoginProvider")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ProviderKey")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ProviderDisplayName")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("LoginProvider", "ProviderKey");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserLogins", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("RoleId")
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetUserRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("LoginProvider")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("Name")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("Value")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("UserId", "LoginProvider", "Name");
+
+ b.ToTable("AspNetUserTokens", (string)null);
+ });
+
+ modelBuilder.Entity("PowderCoating.Core.Entities.Account", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AccountNumber")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("AccountSubType")
+ .HasColumnType("int");
+
+ b.Property("AccountType")
+ .HasColumnType("int");
+
+ b.Property("CompanyId")
+ .HasColumnType("int");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("CurrentBalance")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("DeletedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("DeletedBy")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Description")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("IsActive")
+ .HasColumnType("bit");
+
+ b.Property("IsDeleted")
+ .HasColumnType("bit");
+
+ b.Property("IsSystem")
+ .HasColumnType("bit");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("OpeningBalance")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("OpeningBalanceDate")
+ .HasColumnType("datetime2");
+
+ b.Property("ParentAccountId")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ParentAccountId");
+
+ b.ToTable("Accounts");
+ });
+
+ modelBuilder.Entity("PowderCoating.Core.Entities.AiItemPrediction", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AiTags")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("CompanyId")
+ .HasColumnType("int");
+
+ b.Property("Confidence")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ConversationRounds")
+ .HasColumnType("int");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("DeletedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("DeletedBy")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("IsDeleted")
+ .HasColumnType("bit");
+
+ b.Property("PredictedComplexity")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("PredictedMinutes")
+ .HasColumnType("int");
+
+ b.Property("PredictedSurfaceAreaSqFt")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("PredictedUnitPrice")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("Reasoning")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("UserOverrodeEstimate")
+ .HasColumnType("bit");
+
+ b.HasKey("Id");
+
+ b.ToTable("AiItemPredictions");
+ });
+
+ modelBuilder.Entity("PowderCoating.Core.Entities.AiUsageLog", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("CalledAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CompanyId")
+ .HasColumnType("int");
+
+ b.Property("Feature")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("InputLength")
+ .HasColumnType("int");
+
+ b.Property("Success")
+ .HasColumnType("bit");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CompanyId", "CalledAt")
+ .HasDatabaseName("IX_AiUsageLogs_CompanyId_CalledAt");
+
+ b.ToTable("AiUsageLogs");
+ });
+
+ modelBuilder.Entity("PowderCoating.Core.Entities.Announcement", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedByUserId")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("CreatedByUserName")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("datetime2");
+
+ b.Property("IsActive")
+ .HasColumnType("bit");
+
+ b.Property("IsDismissible")
+ .HasColumnType("bit");
+
+ b.Property("Message")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("StartsAt")
+ .HasColumnType("datetime2");
+
+ b.Property("Target")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("TargetCompanyId")
+ .HasColumnType("int");
+
+ b.Property("TargetPlan")
+ .HasColumnType("int");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.HasKey("Id");
+
+ b.ToTable("Announcements");
+ });
+
+ modelBuilder.Entity("PowderCoating.Core.Entities.AnnouncementDismissal", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AnnouncementId")
+ .HasColumnType("int");
+
+ b.Property("DismissedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AnnouncementId", "UserId")
+ .IsUnique();
+
+ b.ToTable("AnnouncementDismissals");
+ });
+
+ modelBuilder.Entity("PowderCoating.Core.Entities.ApplicationUser", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("AccessFailedCount")
+ .HasColumnType("int");
+
+ b.Property("Address")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("BanReason")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("BannedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("BannedByUserId")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("CanApproveQuotes")
+ .HasColumnType("bit");
+
+ b.Property("CanCreateQuotes")
+ .HasColumnType("bit");
+
+ b.Property("CanManageCalendar")
+ .HasColumnType("bit");
+
+ b.Property("CanManageCustomers")
+ .HasColumnType("bit");
+
+ b.Property("CanManageEquipment")
+ .HasColumnType("bit");
+
+ b.Property("CanManageInventory")
+ .HasColumnType("bit");
+
+ b.Property("CanManageInvoices")
+ .HasColumnType("bit");
+
+ b.Property("CanManageJobs")
+ .HasColumnType("bit");
+
+ b.Property("CanManageMaintenance")
+ .HasColumnType("bit");
+
+ b.Property("CanManageProducts")
+ .HasColumnType("bit");
+
+ b.Property("CanManageVendors")
+ .HasColumnType("bit");
+
+ b.Property("CanViewCalendar")
+ .HasColumnType("bit");
+
+ b.Property("CanViewProducts")
+ .HasColumnType("bit");
+
+ b.Property("CanViewReports")
+ .HasColumnType("bit");
+
+ b.Property("CanViewShopFloor")
+ .HasColumnType("bit");
+
+ b.Property("City")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("CompanyId")
+ .HasColumnType("int");
+
+ b.Property("CompanyRole")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("DashboardLayout")
+ .HasColumnType("int");
+
+ b.Property("DateFormat")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Department")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Email")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("EmailConfirmed")
+ .HasColumnType("bit");
+
+ b.Property("EmployeeNumber")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("FirstName")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("HireDate")
+ .HasColumnType("datetime2");
+
+ b.Property("IsActive")
+ .HasColumnType("bit");
+
+ b.Property("IsBanned")
+ .HasColumnType("bit");
+
+ b.Property("LastLoginDate")
+ .HasColumnType("datetime2");
+
+ b.Property("LastName")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("LockoutEnabled")
+ .HasColumnType("bit");
+
+ b.Property("LockoutEnd")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("NormalizedEmail")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("NormalizedUserName")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("Notes")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("PasskeyPromptDismissed")
+ .HasColumnType("bit");
+
+ b.Property("PasswordHash")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("PhoneNumber")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("PhoneNumberConfirmed")
+ .HasColumnType("bit");
+
+ b.Property("Position")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ProfilePictureFilePath")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("SecurityStamp")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("SidebarColor")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("State")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("TerminationDate")
+ .HasColumnType("datetime2");
+
+ b.Property("Theme")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("TimeZone")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("TwoFactorEnabled")
+ .HasColumnType("bit");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UserName")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("ZipCode")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CompanyId");
+
+ b.HasIndex("NormalizedEmail")
+ .HasDatabaseName("EmailIndex");
+
+ b.HasIndex("NormalizedUserName")
+ .IsUnique()
+ .HasDatabaseName("UserNameIndex")
+ .HasFilter("[NormalizedUserName] IS NOT NULL");
+
+ b.ToTable("AspNetUsers", (string)null);
+ });
+
+ modelBuilder.Entity("PowderCoating.Core.Entities.Appointment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ActualEndTime")
+ .HasColumnType("datetime2");
+
+ b.Property("ActualStartTime")
+ .HasColumnType("datetime2");
+
+ b.Property("AppointmentNumber")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("AppointmentStatusId")
+ .HasColumnType("int");
+
+ b.Property("AppointmentTypeId")
+ .HasColumnType("int");
+
+ b.Property("AssignedUserId")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("CompanyId")
+ .HasColumnType("int");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("CustomerId")
+ .HasColumnType("int");
+
+ b.Property("DeletedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("DeletedBy")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Description")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("IsAllDay")
+ .HasColumnType("bit");
+
+ b.Property("IsDeleted")
+ .HasColumnType("bit");
+
+ b.Property("IsReminderEnabled")
+ .HasColumnType("bit");
+
+ b.Property("JobId")
+ .HasColumnType("int");
+
+ b.Property("Location")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Notes")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ReminderMinutesBefore")
+ .HasColumnType("int");
+
+ b.Property("ScheduledEndTime")
+ .HasColumnType("datetime2");
+
+ b.Property("ScheduledStartTime")
+ .HasColumnType("datetime2");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppointmentStatusId");
+
+ b.HasIndex("AppointmentTypeId");
+
+ b.HasIndex("AssignedUserId");
+
+ b.HasIndex("CustomerId");
+
+ b.HasIndex("JobId");
+
+ b.HasIndex("ScheduledStartTime");
+
+ b.HasIndex("CompanyId", "AppointmentStatusId")
+ .HasDatabaseName("IX_Appointments_CompanyId_AppointmentStatusId");
+
+ b.HasIndex("CompanyId", "ScheduledStartTime")
+ .HasDatabaseName("IX_Appointments_CompanyId_ScheduledStartTime");
+
+ b.ToTable("Appointments");
+ });
+
+ modelBuilder.Entity("PowderCoating.Core.Entities.AppointmentStatusLookup", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ColorClass")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("CompanyId")
+ .HasColumnType("int");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("DeletedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("DeletedBy")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Description")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("DisplayName")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("DisplayOrder")
+ .HasColumnType("int");
+
+ b.Property("IconClass")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("IsActive")
+ .HasColumnType("bit");
+
+ b.Property("IsDeleted")
+ .HasColumnType("bit");
+
+ b.Property("IsSystemDefined")
+ .HasColumnType("bit");
+
+ b.Property("IsTerminalStatus")
+ .HasColumnType("bit");
+
+ b.Property("StatusCode")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("Id");
+
+ b.ToTable("AppointmentStatusLookups");
+ });
+
+ modelBuilder.Entity("PowderCoating.Core.Entities.AppointmentTypeLookup", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ColorClass")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("CompanyId")
+ .HasColumnType("int");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("DeletedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("DeletedBy")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Description")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("DisplayName")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("DisplayOrder")
+ .HasColumnType("int");
+
+ b.Property("IconClass")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("IsActive")
+ .HasColumnType("bit");
+
+ b.Property("IsDeleted")
+ .HasColumnType("bit");
+
+ b.Property