diff --git a/src/PowderCoating.Application/DTOs/Company/CompanySettingsDtos.cs b/src/PowderCoating.Application/DTOs/Company/CompanySettingsDtos.cs
index f8df2c1..f172fd3 100644
--- a/src/PowderCoating.Application/DTOs/Company/CompanySettingsDtos.cs
+++ b/src/PowderCoating.Application/DTOs/Company/CompanySettingsDtos.cs
@@ -44,6 +44,20 @@ namespace PowderCoating.Application.DTOs.Company
/// True when the company has an accepted agreement for the current SmsTermsVersion.
public bool HasCurrentSmsAgreement { get; set; }
public string SmsTermsVersion { get; set; } = string.Empty;
+
+ // Timeclock settings
+ public bool TimeclockEnabled { get; set; }
+ public bool TimeclockAllowMultiplePunchesPerDay { get; set; }
+ public int? TimeclockAutoClockOutHours { get; set; }
+ }
+
+ /// DTO for updating company-level timeclock settings from the Settings tab.
+ public class UpdateTimeclockSettingsDto
+ {
+ public bool TimeclockEnabled { get; set; }
+ public bool TimeclockAllowMultiplePunchesPerDay { get; set; }
+ [Range(1, 24, ErrorMessage = "Auto clock-out must be between 1 and 24 hours.")]
+ public int? TimeclockAutoClockOutHours { get; set; }
}
///
diff --git a/src/PowderCoating.Application/DTOs/Company/CustomItemTemplateDtos.cs b/src/PowderCoating.Application/DTOs/Company/CustomItemTemplateDtos.cs
new file mode 100644
index 0000000..ed1f67b
--- /dev/null
+++ b/src/PowderCoating.Application/DTOs/Company/CustomItemTemplateDtos.cs
@@ -0,0 +1,174 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace PowderCoating.Application.DTOs.Company;
+
+// ============================================================================
+// LIST DTO - For Company Settings tab table
+// ============================================================================
+public class CustomItemTemplateListDto
+{
+ public int Id { get; set; }
+ public string Name { get; set; } = string.Empty;
+ public string? Description { get; set; }
+ public string OutputMode { get; set; } = "FixedRate";
+ public int FieldCount { get; set; }
+ public decimal? DefaultRate { get; set; }
+ public string? RateLabel { get; set; }
+ public bool IsActive { get; set; }
+ public int DisplayOrder { get; set; }
+ public string? DiagramImagePath { get; set; }
+}
+
+// ============================================================================
+// FULL DTO - For Edit modal and formula evaluation
+// ============================================================================
+public class CustomItemTemplateDto
+{
+ public int Id { get; set; }
+ public string Name { get; set; } = string.Empty;
+ public string? Description { get; set; }
+ public string OutputMode { get; set; } = "FixedRate";
+ public string FieldsJson { get; set; } = "[]";
+ public string Formula { get; set; } = string.Empty;
+ public decimal? DefaultRate { get; set; }
+ public string? RateLabel { get; set; }
+ public string? Notes { get; set; }
+ public int DisplayOrder { get; set; }
+ public bool IsActive { get; set; }
+ public string? DiagramImagePath { get; set; }
+}
+
+// ============================================================================
+// CREATE DTO
+// ============================================================================
+public class CreateCustomItemTemplateDto
+{
+ [Required]
+ [StringLength(100)]
+ public string Name { get; set; } = string.Empty;
+
+ [StringLength(500)]
+ public string? Description { get; set; }
+
+ /// "FixedRate" or "SurfaceAreaSqFt"
+ [Required]
+ public string OutputMode { get; set; } = "FixedRate";
+
+ /// JSON array of field definitions: [{name, label, unit, defaultValue}]
+ [Required]
+ public string FieldsJson { get; set; } = "[]";
+
+ [Required]
+ public string Formula { get; set; } = string.Empty;
+
+ public decimal? DefaultRate { get; set; }
+
+ [StringLength(50)]
+ public string? RateLabel { get; set; }
+
+ [StringLength(1000)]
+ public string? Notes { get; set; }
+
+ public int DisplayOrder { get; set; }
+ public bool IsActive { get; set; } = true;
+}
+
+// ============================================================================
+// UPDATE DTO
+// ============================================================================
+public class UpdateCustomItemTemplateDto
+{
+ public int Id { get; set; }
+
+ [Required]
+ [StringLength(100)]
+ public string Name { get; set; } = string.Empty;
+
+ [StringLength(500)]
+ public string? Description { get; set; }
+
+ [Required]
+ public string OutputMode { get; set; } = "FixedRate";
+
+ [Required]
+ public string FieldsJson { get; set; } = "[]";
+
+ [Required]
+ public string Formula { get; set; } = string.Empty;
+
+ public decimal? DefaultRate { get; set; }
+
+ [StringLength(50)]
+ public string? RateLabel { get; set; }
+
+ [StringLength(1000)]
+ public string? Notes { get; set; }
+
+ public int DisplayOrder { get; set; }
+ public bool IsActive { get; set; } = true;
+
+ /// Existing diagram path — kept if no new file is uploaded.
+ public string? DiagramImagePath { get; set; }
+}
+
+// ============================================================================
+// WIZARD PICKER DTO - Lean DTO for populating the quote wizard template list
+// ============================================================================
+public class CustomItemTemplatePickerDto
+{
+ public int Id { get; set; }
+ public string Name { get; set; } = string.Empty;
+ public string? Description { get; set; }
+ public string OutputMode { get; set; } = "FixedRate";
+ public string FieldsJson { get; set; } = "[]";
+ public string Formula { get; set; } = string.Empty;
+ public decimal? DefaultRate { get; set; }
+ public string? RateLabel { get; set; }
+ public string? DiagramImagePath { get; set; }
+}
+
+// ============================================================================
+// AI GENERATION DTOs
+// ============================================================================
+public class GenerateFormulaFromAiRequest
+{
+ [Required]
+ public string Description { get; set; } = string.Empty;
+}
+
+public class GenerateFormulaFromAiResponse
+{
+ public bool Success { get; set; }
+ public string? Error { get; set; }
+ public string? Name { get; set; }
+ public string? OutputMode { get; set; }
+ public string? FieldsJson { get; set; }
+ public string? Formula { get; set; }
+ public decimal? DefaultRate { get; set; }
+ public string? RateLabel { get; set; }
+ public string? Reasoning { get; set; }
+
+ /// Result of running the formula with any sample values found in the description.
+ public decimal? VerificationResult { get; set; }
+ public string? VerificationInputs { get; set; }
+}
+
+// ============================================================================
+// FORMULA EVALUATION DTOs
+// ============================================================================
+public class EvaluateFormulaRequest
+{
+ [Required]
+ public string Formula { get; set; } = string.Empty;
+
+ /// JSON object of variable name → value pairs, e.g. {"box_l": 43, "rate": 0.05}
+ [Required]
+ public string VariablesJson { get; set; } = "{}";
+}
+
+public class EvaluateFormulaResponse
+{
+ public bool Success { get; set; }
+ public decimal? Result { get; set; }
+ public string? Error { get; set; }
+}
diff --git a/src/PowderCoating.Application/DTOs/Company/FormulaLibraryDtos.cs b/src/PowderCoating.Application/DTOs/Company/FormulaLibraryDtos.cs
new file mode 100644
index 0000000..bb3524d
--- /dev/null
+++ b/src/PowderCoating.Application/DTOs/Company/FormulaLibraryDtos.cs
@@ -0,0 +1,78 @@
+namespace PowderCoating.Application.DTOs.Company;
+
+// ── Browse / card display ──────────────────────────────────────────────────
+
+/// Lean DTO for the community library browse grid card.
+public class FormulaLibraryCardDto
+{
+ public int Id { get; set; }
+ public string Name { get; set; } = string.Empty;
+ public string? Description { get; set; }
+ public string OutputMode { get; set; } = "FixedRate";
+ public string? Tags { get; set; }
+ public string? IndustryHint { get; set; }
+ public string SourceCompanyName { get; set; } = string.Empty;
+ public int ImportCount { get; set; }
+ public DateTime SharedAt { get; set; }
+ public string? DiagramImagePath { get; set; }
+
+ /// Non-null when this formula was derived from another library entry.
+ public int? InspiredByFormulaLibraryItemId { get; set; }
+ public string? InspiredByName { get; set; }
+ public string? InspiredByCompanyName { get; set; }
+
+ /// True when the current company has already imported this entry.
+ public bool AlreadyImported { get; set; }
+
+ /// True when this formula was shared by the current browsing company.
+ public bool IsOwnFormula { get; set; }
+
+ /// Total thumbs-up votes across all companies.
+ public int ThumbsUp { get; set; }
+
+ /// Total thumbs-down votes across all companies.
+ public int ThumbsDown { get; set; }
+
+ /// The current browsing company's vote: true = up, false = down, null = no vote.
+ public bool? MyVote { get; set; }
+}
+
+// ── Full detail (import preview modal) ────────────────────────────────────
+
+/// Full DTO used in the import preview modal — shows fields and formula.
+public class FormulaLibraryDetailDto : FormulaLibraryCardDto
+{
+ public string FieldsJson { get; set; } = "[]";
+ public string Formula { get; set; } = string.Empty;
+ public decimal? DefaultRate { get; set; }
+ public string? RateLabel { get; set; }
+ public string? Notes { get; set; }
+ public int FieldCount { get; set; }
+}
+
+// ── Share from Company Settings ───────────────────────────────────────────
+
+/// Submitted when a company admin shares one of their templates to the community library.
+public class ShareFormulaRequest
+{
+ public int CustomItemTemplateId { get; set; }
+ public string? Tags { get; set; }
+ public string? IndustryHint { get; set; }
+}
+
+// ── Company Settings list view ─────────────────────────────────────────────
+
+/// Status of a template relative to the community library, shown in Company Settings.
+public class FormulaLibraryStatusDto
+{
+ /// The FormulaLibraryItem Id, if this template has ever been shared.
+ public int? LibraryItemId { get; set; }
+ public bool IsPublished { get; set; }
+
+ /// Whether this template is eligible to be shared (original or modified import).
+ public bool CanShare { get; set; }
+
+ /// Set when this template was imported; the name of the original library entry.
+ public string? ImportedFromName { get; set; }
+ public string? ImportedFromCompany { get; set; }
+}
diff --git a/src/PowderCoating.Application/DTOs/Import/JobImportDto.cs b/src/PowderCoating.Application/DTOs/Import/JobImportDto.cs
index b35e053..d134dad 100644
--- a/src/PowderCoating.Application/DTOs/Import/JobImportDto.cs
+++ b/src/PowderCoating.Application/DTOs/Import/JobImportDto.cs
@@ -21,6 +21,11 @@ public class JobImportDto
[Name("CustomerName")]
public string? CustomerName { get; set; }
+ // Optional short label for the job (maps directly to Job.Description).
+ // When blank, the system falls back to SpecialInstructions, then "Imported job".
+ [Name("Description")]
+ public string? Description { get; set; }
+
[Name("Status")]
public string Status { get; set; } = "Pending";
diff --git a/src/PowderCoating.Application/DTOs/Job/JobDtos.cs b/src/PowderCoating.Application/DTOs/Job/JobDtos.cs
index da088ec..1eef12c 100644
--- a/src/PowderCoating.Application/DTOs/Job/JobDtos.cs
+++ b/src/PowderCoating.Application/DTOs/Job/JobDtos.cs
@@ -325,7 +325,11 @@ public class JobItemDto
public bool IsGenericItem { get; set; }
public bool IsLaborItem { get; set; }
public bool IsSalesItem { get; set; }
+ public bool IsAiItem { get; set; }
public string? Sku { get; set; }
+ public bool IsCustomFormulaItem { get; set; }
+ public int? CustomItemTemplateId { get; set; }
+ public string? FormulaFieldValuesJson { get; set; }
public List Coats { get; set; } = new();
public List PrepServices { get; set; } = new();
}
diff --git a/src/PowderCoating.Application/DTOs/Quote/QuoteDtos.cs b/src/PowderCoating.Application/DTOs/Quote/QuoteDtos.cs
index b754921..0bb9c48 100644
--- a/src/PowderCoating.Application/DTOs/Quote/QuoteDtos.cs
+++ b/src/PowderCoating.Application/DTOs/Quote/QuoteDtos.cs
@@ -475,6 +475,11 @@ public class QuoteItemDto
public bool IsAiItem { get; set; }
+ // Custom formula item
+ public bool IsCustomFormulaItem { get; set; }
+ public int? CustomItemTemplateId { get; set; }
+ public string? FormulaFieldValuesJson { get; set; }
+
// Cost breakdown snapshot
public decimal ItemMaterialCost { get; set; }
public decimal ItemLaborCost { get; set; }
@@ -559,6 +564,11 @@ public class CreateQuoteItemDto
// ID of the AiItemPrediction record captured at analysis time (null for non-AI items)
public int? AiPredictionId { get; set; }
+
+ // Custom formula item routing — see IsCustomFormulaItem in PricingCalculationService
+ public bool IsCustomFormulaItem { get; set; }
+ public int? CustomItemTemplateId { get; set; }
+ public string? FormulaFieldValuesJson { get; set; }
}
// ============================================================================
@@ -874,4 +884,9 @@ public class QuotePricingResult
// Per-item results (same order as input items)
public List ItemResults { get; set; } = new();
+
+ // Pending Custom Powder Order preview — populated only when no "Custom Powder Order" item
+ // exists yet (first save scenario). Amount and color list let the UI show a preview row.
+ public decimal CustomPowderOrderAmount { get; set; }
+ public List CustomPowderOrderColors { get; set; } = new();
}
diff --git a/src/PowderCoating.Application/DTOs/Subscription/SubscriptionPlanConfigDto.cs b/src/PowderCoating.Application/DTOs/Subscription/SubscriptionPlanConfigDto.cs
index cc69d6f..ad61926 100644
--- a/src/PowderCoating.Application/DTOs/Subscription/SubscriptionPlanConfigDto.cs
+++ b/src/PowderCoating.Application/DTOs/Subscription/SubscriptionPlanConfigDto.cs
@@ -26,6 +26,7 @@ public class SubscriptionPlanConfigDto
public bool AllowAiInventoryAssist { get; set; }
public bool AllowAiCatalogPriceCheck { get; set; }
public bool AllowSms { get; set; }
+ public bool AllowCustomFormulas { get; set; }
public bool IsActive { get; set; }
public int SortOrder { get; set; }
}
@@ -74,6 +75,7 @@ public class UpdateSubscriptionPlanConfigDto
public bool AllowAiInventoryAssist { get; set; }
public bool AllowAiCatalogPriceCheck { get; set; }
public bool AllowSms { get; set; }
+ public bool AllowCustomFormulas { get; set; }
public bool IsActive { get; set; }
}
diff --git a/src/PowderCoating.Application/DTOs/Timeclock/TimeclockDtos.cs b/src/PowderCoating.Application/DTOs/Timeclock/TimeclockDtos.cs
new file mode 100644
index 0000000..13e177b
--- /dev/null
+++ b/src/PowderCoating.Application/DTOs/Timeclock/TimeclockDtos.cs
@@ -0,0 +1,74 @@
+using PowderCoating.Core.Enums;
+
+namespace PowderCoating.Application.DTOs.Timeclock;
+
+public class EmployeeClockEntryDto
+{
+ public int Id { get; set; }
+ public string UserId { get; set; } = string.Empty;
+ public string UserDisplayName { get; set; } = string.Empty;
+ public DateTime ClockInTime { get; set; }
+ public DateTime? ClockOutTime { get; set; }
+ public decimal? HoursWorked { get; set; }
+ public ClockEntryType EntryType { get; set; } = ClockEntryType.Work;
+ public string? Notes { get; set; }
+ public bool IsOpen => ClockOutTime == null;
+}
+
+public class ClockInRequest
+{
+ public string? Notes { get; set; }
+}
+
+public class ClockOutRequest
+{
+ public int EntryId { get; set; }
+ public string? Notes { get; set; }
+}
+
+///
+/// Request sent from the kiosk tablet — employee taps their tile and enters a PIN.
+/// The server determines whether to clock in or clock out based on the employee's open entry.
+///
+public class KioskPunchRequest
+{
+ public string UserId { get; set; } = string.Empty;
+ public string Pin { get; set; } = string.Empty;
+}
+
+public class EditClockEntryRequest
+{
+ public int Id { get; set; }
+ public DateTime ClockInTime { get; set; }
+ public DateTime? ClockOutTime { get; set; }
+ public string? Notes { get; set; }
+}
+
+///
+/// Sent when an employee clicks Break or Lunch to pause their work segment.
+/// The server closes the current Work entry and opens a Break/Lunch entry.
+///
+public class GoOnBreakRequest
+{
+ /// Must be or .
+ public ClockEntryType BreakType { get; set; }
+}
+
+/// Manager request to create a time entry on behalf of any company employee.
+public class ManualEntryRequest
+{
+ public string UserId { get; set; } = string.Empty;
+ public DateTime ClockInTime { get; set; }
+ public DateTime? ClockOutTime { get; set; }
+ public string? Notes { get; set; }
+}
+
+/// Employee tile shown on the kiosk employee-selection grid.
+public class KioskEmployeeDto
+{
+ public string UserId { get; set; } = string.Empty;
+ public string DisplayName { get; set; } = string.Empty;
+ public string Initials { get; set; } = string.Empty;
+ /// True when the employee has an open clock entry right now.
+ public bool IsClockedIn { get; set; }
+}
diff --git a/src/PowderCoating.Application/DTOs/Wizard/WizardDtos.cs b/src/PowderCoating.Application/DTOs/Wizard/WizardDtos.cs
index e559ab1..025dc1e 100644
--- a/src/PowderCoating.Application/DTOs/Wizard/WizardDtos.cs
+++ b/src/PowderCoating.Application/DTOs/Wizard/WizardDtos.cs
@@ -18,7 +18,8 @@ public class WizardProgressDto
public bool IsStepSkipped(int step) => SkippedSteps.Contains(step);
public bool IsStepTouched(int step) => IsStepDone(step) || IsStepSkipped(step);
- public int CompletedCount => DoneSteps.Count + SkippedSteps.Count;
+ // Capped at TotalSteps so old step data from a larger wizard doesn't overflow the display.
+ public int CompletedCount => Math.Min(DoneSteps.Count + SkippedSteps.Count, TotalSteps);
public int ProgressPercent => TotalSteps == 0 ? 0 : (int)Math.Round((double)CompletedCount / TotalSteps * 100);
}
diff --git a/src/PowderCoating.Application/Interfaces/ICustomFormulaAiService.cs b/src/PowderCoating.Application/Interfaces/ICustomFormulaAiService.cs
new file mode 100644
index 0000000..2a55c59
--- /dev/null
+++ b/src/PowderCoating.Application/Interfaces/ICustomFormulaAiService.cs
@@ -0,0 +1,30 @@
+using PowderCoating.Application.DTOs.Company;
+
+namespace PowderCoating.Application.Interfaces;
+
+public interface ICustomFormulaAiService
+{
+ ///
+ /// Generates a NCalc formula, field list, and notes from a natural-language description
+ /// and an optional diagram image. Returns a
+ /// ready to pre-fill the template editor.
+ ///
+ Task GenerateFormulaAsync(
+ GenerateFormulaFromAiRequest request,
+ byte[]? imageBytes = null,
+ string? imageContentType = null);
+
+ ///
+ /// Evaluates a NCalc formula with the supplied variable map and returns the numeric result.
+ /// Safe server-side only — no user-controlled code execution.
+ ///
+ EvaluateFormulaResponse EvaluateFormula(EvaluateFormulaRequest request);
+
+ ///
+ /// Normalizes NCalc built-in function names to lowercase (IF→if, Abs→abs, etc.) then
+ /// attempts a parse-only evaluation to catch syntax errors before the formula is saved.
+ /// Returns the normalized formula string and a null error on success, or the original
+ /// formula and an error message on failure.
+ ///
+ (string NormalizedFormula, string? Error) NormalizeAndValidate(string formula);
+}
diff --git a/src/PowderCoating.Application/Interfaces/IFormulaLibraryService.cs b/src/PowderCoating.Application/Interfaces/IFormulaLibraryService.cs
new file mode 100644
index 0000000..e5e8373
--- /dev/null
+++ b/src/PowderCoating.Application/Interfaces/IFormulaLibraryService.cs
@@ -0,0 +1,61 @@
+using PowderCoating.Application.DTOs.Company;
+
+namespace PowderCoating.Application.Interfaces;
+
+///
+/// Manages the community formula library: sharing, unsharing, importing, and browsing.
+///
+public interface IFormulaLibraryService
+{
+ ///
+ /// Returns all published library entries, with AlreadyImported populated for the given company.
+ /// Optionally filters by search term, output mode, or industry hint.
+ ///
+ Task> BrowseAsync(
+ int companyId,
+ string? search = null,
+ string? outputMode = null,
+ string? industryHint = null);
+
+ /// Full detail for the import preview modal, including field list and formula.
+ Task GetDetailAsync(int libraryItemId, int companyId);
+
+ ///
+ /// Publishes a company template to the community library.
+ /// If the template was previously shared and unpublished, re-publishes the existing row.
+ /// Updates the library entry fields from the current template state on re-share.
+ ///
+ Task ShareAsync(int companyId, string userId, ShareFormulaRequest request);
+
+ /// Sets IsPublished = false. Existing imports are unaffected.
+ Task UnshareAsync(int libraryItemId, int companyId);
+
+ ///
+ /// Copies a library entry into the company's local CustomItemTemplate table.
+ /// If the company already has an import record for this entry, returns the existing template id.
+ ///
+ Task ImportAsync(int libraryItemId, int companyId, string userId);
+
+ ///
+ /// Returns the library status for a given CustomItemTemplate — whether it is shared,
+ /// eligible to be shared, and where it was imported from if applicable.
+ ///
+ Task GetTemplateLibraryStatusAsync(int templateId, int companyId);
+
+ ///
+ /// Nulls out DiagramImagePath on the FormulaLibraryItem and all imported copies
+ /// when a source template's diagram is removed. Call from CompanySettingsController
+ /// when a diagram is deleted or replaced.
+ ///
+ Task CascadeRemoveDiagramAsync(int sourceCustomItemTemplateId);
+
+ ///
+ /// Records or toggles a thumbs-up/down vote from the given company.
+ /// If the same vote already exists it is removed (toggle off).
+ /// If the opposite vote exists it is replaced.
+ /// Companies cannot rate their own formulas.
+ /// Returns the updated counts for the library entry.
+ ///
+ Task<(int ThumbsUp, int ThumbsDown, bool? MyVote)> RateAsync(
+ int libraryItemId, int companyId, bool isPositive);
+}
diff --git a/src/PowderCoating.Application/Interfaces/IQuotePricingAssemblyService.cs b/src/PowderCoating.Application/Interfaces/IQuotePricingAssemblyService.cs
index 24e5af8..ef5aa50 100644
--- a/src/PowderCoating.Application/Interfaces/IQuotePricingAssemblyService.cs
+++ b/src/PowderCoating.Application/Interfaces/IQuotePricingAssemblyService.cs
@@ -13,4 +13,12 @@ public interface IQuotePricingAssemblyService
int companyId,
decimal? ovenRateOverride,
DateTime createdAtUtc);
+
+ ///
+ /// Creates one (IsIncoming=true) per unique powder catalog entry
+ /// referenced by coats on the given quote, then links those coats to the new inventory records.
+ /// Must be called after a quote transitions to Approved status.
+ /// Safe to call multiple times — coats that already have an InventoryItemId are skipped.
+ ///
+ Task EnsureIncomingInventoryForApprovedQuoteAsync(int quoteId, int companyId);
}
diff --git a/src/PowderCoating.Application/Mappings/CustomItemTemplateProfile.cs b/src/PowderCoating.Application/Mappings/CustomItemTemplateProfile.cs
new file mode 100644
index 0000000..d212035
--- /dev/null
+++ b/src/PowderCoating.Application/Mappings/CustomItemTemplateProfile.cs
@@ -0,0 +1,41 @@
+using AutoMapper;
+using PowderCoating.Core.Entities;
+using PowderCoating.Application.DTOs.Company;
+
+namespace PowderCoating.Application.Mappings;
+
+public class CustomItemTemplateProfile : Profile
+{
+ public CustomItemTemplateProfile()
+ {
+ CreateMap()
+ .ForMember(dest => dest.FieldCount,
+ opt => opt.MapFrom(src => CountFields(src.FieldsJson)));
+
+ CreateMap();
+
+ CreateMap();
+
+ CreateMap();
+
+ CreateMap()
+ .ForMember(dest => dest.DiagramImagePath, opt => opt.Ignore()); // set by controller after blob upload
+
+ CreateMap();
+ }
+
+ private static int CountFields(string fieldsJson)
+ {
+ try
+ {
+ var doc = System.Text.Json.JsonDocument.Parse(fieldsJson);
+ return doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Array
+ ? doc.RootElement.GetArrayLength()
+ : 0;
+ }
+ catch
+ {
+ return 0;
+ }
+ }
+}
diff --git a/src/PowderCoating.Application/Mappings/FormulaLibraryProfile.cs b/src/PowderCoating.Application/Mappings/FormulaLibraryProfile.cs
new file mode 100644
index 0000000..e5ca5a2
--- /dev/null
+++ b/src/PowderCoating.Application/Mappings/FormulaLibraryProfile.cs
@@ -0,0 +1,35 @@
+using AutoMapper;
+using PowderCoating.Core.Entities;
+using PowderCoating.Application.DTOs.Company;
+
+namespace PowderCoating.Application.Mappings;
+
+public class FormulaLibraryProfile : Profile
+{
+ public FormulaLibraryProfile()
+ {
+ CreateMap()
+ .ForMember(dest => dest.InspiredByName,
+ opt => opt.MapFrom(src => src.InspiredBy != null ? src.InspiredBy.Name : null))
+ .ForMember(dest => dest.InspiredByCompanyName,
+ opt => opt.MapFrom(src => src.InspiredBy != null ? src.InspiredBy.SourceCompanyName : null))
+ .ForMember(dest => dest.AlreadyImported, opt => opt.Ignore()); // set by service
+
+ CreateMap()
+ .IncludeBase()
+ .ForMember(dest => dest.FieldCount,
+ opt => opt.MapFrom(src => CountFields(src.FieldsJson)));
+ }
+
+ private static int CountFields(string fieldsJson)
+ {
+ try
+ {
+ var doc = System.Text.Json.JsonDocument.Parse(fieldsJson);
+ return doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Array
+ ? doc.RootElement.GetArrayLength()
+ : 0;
+ }
+ catch { return 0; }
+ }
+}
diff --git a/src/PowderCoating.Application/Mappings/QuoteProfile.cs b/src/PowderCoating.Application/Mappings/QuoteProfile.cs
index e4b845b..49788ce 100644
--- a/src/PowderCoating.Application/Mappings/QuoteProfile.cs
+++ b/src/PowderCoating.Application/Mappings/QuoteProfile.cs
@@ -159,6 +159,7 @@ public class QuoteProfile : Profile
.ReverseMap()
.ForMember(dest => dest.Quote, opt => opt.Ignore())
.ForMember(dest => dest.CatalogItem, opt => opt.Ignore())
+ .ForMember(dest => dest.CustomItemTemplate, opt => opt.Ignore())
.ForMember(dest => dest.Coats, opt => opt.Ignore())
.ForMember(dest => dest.PrepServices, opt => opt.Ignore())
.ForMember(dest => dest.CompanyId, opt => opt.Ignore())
@@ -180,6 +181,7 @@ public class QuoteProfile : Profile
.ForMember(dest => dest.Coats, opt => opt.Ignore()) // Mapped separately
.ForMember(dest => dest.PrepServices, opt => opt.Ignore()) // Mapped separately
.ForMember(dest => dest.CatalogItem, opt => opt.Ignore())
+ .ForMember(dest => dest.CustomItemTemplate, opt => opt.Ignore()) // FK only; nav set by EF
.ForMember(dest => dest.CompanyId, opt => opt.Ignore())
.ForMember(dest => dest.CreatedAt, opt => opt.Ignore())
.ForMember(dest => dest.UpdatedAt, opt => opt.Ignore())
@@ -190,7 +192,10 @@ public class QuoteProfile : Profile
.ForMember(dest => dest.UpdatedBy, opt => opt.Ignore());
// QuoteItem -> CreateQuoteItemDto (for Edit view)
+ // Coats and PrepServices must be mapped explicitly; convention-based collection mapping
+ // is unreliable for ICollection → List with different element types.
CreateMap()
+ .ForMember(dest => dest.Coats, opt => opt.MapFrom(src => src.Coats))
.ForMember(dest => dest.PrepServices, opt => opt.MapFrom(src => src.PrepServices));
// ============================================================================
diff --git a/src/PowderCoating.Application/PowderCoating.Application.csproj b/src/PowderCoating.Application/PowderCoating.Application.csproj
index 21bf084..394194b 100644
--- a/src/PowderCoating.Application/PowderCoating.Application.csproj
+++ b/src/PowderCoating.Application/PowderCoating.Application.csproj
@@ -16,6 +16,7 @@
+
diff --git a/src/PowderCoating.Application/Services/JobItemAssemblyService.cs b/src/PowderCoating.Application/Services/JobItemAssemblyService.cs
index bc2a240..2f301da 100644
--- a/src/PowderCoating.Application/Services/JobItemAssemblyService.cs
+++ b/src/PowderCoating.Application/Services/JobItemAssemblyService.cs
@@ -53,7 +53,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
IncludePrepCost = source.IncludePrepCost,
Complexity = source.Complexity,
AiTags = source.AiTags,
- AiPredictionId = source.AiPredictionId
+ AiPredictionId = source.AiPredictionId,
+ IsCustomFormulaItem = source.IsCustomFormulaItem,
+ CustomItemTemplateId = source.CustomItemTemplateId,
+ FormulaFieldValuesJson = source.FormulaFieldValuesJson
},
jobId,
companyId,
@@ -157,7 +160,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
IncludePrepCost = source.IncludePrepCost,
Complexity = source.Complexity,
AiTags = source.AiTags,
- AiPredictionId = source.AiPredictionId
+ AiPredictionId = source.AiPredictionId,
+ IsCustomFormulaItem = source.IsCustomFormulaItem,
+ CustomItemTemplateId = source.CustomItemTemplateId,
+ FormulaFieldValuesJson = source.FormulaFieldValuesJson
},
jobId,
companyId,
@@ -259,7 +265,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
IncludePrepCost = source.IncludePrepCost,
Complexity = source.Complexity,
AiTags = source.AiTags,
- AiPredictionId = source.AiPredictionId
+ AiPredictionId = source.AiPredictionId,
+ IsCustomFormulaItem = source.IsCustomFormulaItem,
+ CustomItemTemplateId = source.CustomItemTemplateId,
+ FormulaFieldValuesJson = source.FormulaFieldValuesJson
},
jobId,
companyId,
@@ -353,6 +362,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
Complexity = seed.Complexity,
AiTags = seed.AiTags,
AiPredictionId = seed.AiPredictionId,
+ IsCustomFormulaItem = seed.IsCustomFormulaItem,
+ CustomItemTemplateId = seed.CustomItemTemplateId,
+ FormulaFieldValuesJson = seed.FormulaFieldValuesJson,
CompanyId = companyId,
CreatedAt = createdAtUtc
};
@@ -480,6 +492,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
public string? Complexity { get; init; }
public string? AiTags { get; init; }
public int? AiPredictionId { get; init; }
+ public bool IsCustomFormulaItem { get; init; }
+ public int? CustomItemTemplateId { get; init; }
+ public string? FormulaFieldValuesJson { get; init; }
}
/// Intermediate value object for coat creation — see for rationale.
diff --git a/src/PowderCoating.Application/Services/PricingCalculationService.cs b/src/PowderCoating.Application/Services/PricingCalculationService.cs
index c56b501..5fbc1c6 100644
--- a/src/PowderCoating.Application/Services/PricingCalculationService.cs
+++ b/src/PowderCoating.Application/Services/PricingCalculationService.cs
@@ -220,6 +220,16 @@ public class PricingCalculationService : IPricingCalculationService
};
}
+ ///
+ /// Returns true when a coat requires ordering custom powder that is not in inventory.
+ /// Only coats with an explicit PowderToOrder quantity qualify — coats without a quantity
+ /// fall through to the standard surface-area pricing path in CalculateCoatPriceAsync.
+ ///
+ private static bool IsCustomPowderCoat(CreateQuoteItemCoatDto coat) =>
+ !coat.InventoryItemId.HasValue &&
+ coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0 &&
+ coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0;
+
///
/// Calculates the total price for a single quote line item, routing to the correct pricing
/// path based on item type:
@@ -288,6 +298,27 @@ public class PricingCalculationService : IPricingCalculationService
};
}
+ // Custom formula items (FixedRate mode): the wizard evaluated the NCalc formula server-side
+ // and stored the result as ManualUnitPrice. The formula result IS the total price — it already
+ // incorporates any quantity-like fields the user entered (e.g. numWheels, numParts). Do NOT
+ // multiply by Quantity again; doing so double-counts when the formula itself accounts for qty.
+ // SurfaceAreaSqFt mode: ManualUnitPrice is null; the formula produced sqft which was stored
+ // in SurfaceAreaSqFt, so the item falls through to the standard calculated path below.
+ if (item.IsCustomFormulaItem && item.ManualUnitPrice.HasValue)
+ {
+ var formulaTotal = item.ManualUnitPrice.Value;
+ var formulaUnitPrice = item.Quantity > 0 ? formulaTotal / item.Quantity : formulaTotal;
+ return new QuoteItemPricingResult
+ {
+ MaterialCost = 0,
+ LaborCost = 0,
+ EquipmentCost = 0,
+ ItemSubtotal = formulaTotal,
+ UnitPrice = formulaUnitPrice,
+ TotalPrice = formulaTotal
+ };
+ }
+
// Sales items (off-the-shelf merchandise) — manual unit price, no coating calculation.
if (item.IsSalesItem && item.ManualUnitPrice.HasValue)
{
@@ -312,6 +343,8 @@ public class PricingCalculationService : IPricingCalculationService
{
for (int i = 0; i < item.Coats.Count; i++)
{
+ // Custom powder material moves to the "Custom Powder Order" line item
+ if (IsCustomPowderCoat(item.Coats[i])) continue;
var coatResult = await CalculateCoatPriceAsync(
item.Coats[i], 0m, item.Quantity, i, 0, companyId);
coatMaterialCost += coatResult.CoatMaterialCost;
@@ -413,7 +446,9 @@ public class PricingCalculationService : IPricingCalculationService
for (int ci = 0; ci < item.Coats.Count; ci++)
{
var coat = item.Coats[ci];
- if (!coat.InventoryItemId.HasValue && coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0)
+ // Custom powder with PowderToOrder moves to the "Custom Powder Order" line item; skip here
+ if (!coat.InventoryItemId.HasValue && coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0
+ && !IsCustomPowderCoat(coat))
{
var coatResult = await CalculateCoatPriceAsync(coat, item.SurfaceAreaSqFt, item.Quantity, ci, 0, companyId);
totalMaterialCost += coatResult.CoatMaterialCost;
@@ -431,7 +466,8 @@ public class PricingCalculationService : IPricingCalculationService
{
var firstCoatResult = await CalculateCoatPriceAsync(
item.Coats[0], item.SurfaceAreaSqFt, item.Quantity, 0, item.EstimatedMinutes, companyId);
- totalMaterialCost = firstCoatResult.CoatMaterialCost;
+ // Custom powder material moves to the "Custom Powder Order" line item; keep the labor
+ totalMaterialCost = IsCustomPowderCoat(item.Coats[0]) ? 0m : firstCoatResult.CoatMaterialCost;
coatLaborCost = firstCoatResult.CoatLaborCost;
totalLaborCost = coatLaborCost;
}
@@ -628,6 +664,49 @@ public class PricingCalculationService : IPricingCalculationService
// 4. TOTAL ITEMS SUBTOTAL
var itemsSubtotal = catalogItemsWithoutCoatsTotal + calculatedItemsSubtotal;
+ // Powder-to-order costs are excluded from individual item prices and collected in a
+ // "Custom Powder Order" line item added at save time. For live pricing previews (before
+ // save), add them back here so the displayed total stays correct throughout the session.
+ // Two coat types qualify: custom powder (no InventoryItemId, manual PowderCostPerLb) and
+ // incoming powder (InventoryItemId set, IsIncoming=true, cost from inventoryItem.UnitCost).
+ bool hasCustomPowderOrderItem = items.Any(i =>
+ i.IsGenericItem && i.Description?.StartsWith("Custom Powder Order") == true);
+ decimal customPowderOrderAmount = 0m;
+ var customPowderOrderColors = new List();
+ if (!hasCustomPowderOrderItem)
+ {
+ foreach (var item in items.Where(i => i.Coats != null))
+ {
+ foreach (var c in item.Coats!)
+ {
+ if (!c.InventoryItemId.HasValue &&
+ c.PowderToOrder.HasValue && c.PowderToOrder.Value > 0 &&
+ c.PowderCostPerLb.HasValue && c.PowderCostPerLb.Value > 0)
+ {
+ customPowderOrderAmount += c.PowderToOrder.Value * c.PowderCostPerLb.Value;
+ if (!string.IsNullOrWhiteSpace(c.ColorName))
+ customPowderOrderColors.Add(c.ColorName);
+ }
+ else if (c.InventoryItemId.HasValue && c.PowderToOrder.HasValue && c.PowderToOrder.Value > 0)
+ {
+ var invItem = await _unitOfWork.InventoryItems.GetByIdAsync(c.InventoryItemId.Value);
+ if (invItem?.IsIncoming == true)
+ {
+ customPowderOrderAmount += c.PowderToOrder.Value * invItem.UnitCost;
+ var colorName = !string.IsNullOrWhiteSpace(c.ColorName) ? c.ColorName : invItem.Name;
+ if (!string.IsNullOrWhiteSpace(colorName))
+ customPowderOrderColors.Add(colorName);
+ }
+ }
+ }
+ }
+ if (customPowderOrderAmount > 0)
+ {
+ itemsSubtotal += customPowderOrderAmount;
+ totalMaterialCosts += customPowderOrderAmount;
+ }
+ }
+
// 4b. OVEN BATCH COST (quote-level: batches × cycle time × oven rate)
// AI items already have oven cost baked into their AI-estimated price, so we only
// charge the proportion of the oven that's attributable to non-AI items.
@@ -806,7 +885,11 @@ public class PricingCalculationService : IPricingCalculationService
MaterialCosts = Math.Round(totalMaterialCosts, 2),
LaborCosts = Math.Round(totalLaborCosts, 2),
EquipmentCosts = Math.Round(totalEquipmentCosts, 2),
- ItemResults = itemResults
+ ItemResults = itemResults,
+ CustomPowderOrderAmount = Math.Round(customPowderOrderAmount, 2),
+ CustomPowderOrderColors = customPowderOrderColors
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList()
};
}
}
diff --git a/src/PowderCoating.Application/Services/QuotePricingAssemblyService.cs b/src/PowderCoating.Application/Services/QuotePricingAssemblyService.cs
index db45f13..ef10807 100644
--- a/src/PowderCoating.Application/Services/QuotePricingAssemblyService.cs
+++ b/src/PowderCoating.Application/Services/QuotePricingAssemblyService.cs
@@ -90,8 +90,9 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
{
ArgumentNullException.ThrowIfNull(itemDtos);
+ var dtoList = itemDtos.ToList();
var items = new List();
- foreach (var itemDto in itemDtos)
+ foreach (var itemDto in dtoList)
{
var item = BuildQuoteItem(itemDto, quoteId, companyId, createdAtUtc);
await ApplyPricingAsync(item, itemDto, companyId, ovenRateOverride);
@@ -102,6 +103,17 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
items.Add(item);
}
+ // Option B: auto-create the Custom Powder Order item only on first save.
+ // Once user-owned, they manage its price (e.g. to add shipping) — we never overwrite it.
+ bool hasExistingCustomPowderOrder = dtoList.Any(d =>
+ d.IsGenericItem && d.Description?.StartsWith("Custom Powder Order") == true);
+ if (!hasExistingCustomPowderOrder)
+ {
+ var customPowderItem = await BuildCustomPowderOrderItemAsync(dtoList, quoteId, companyId, createdAtUtc);
+ if (customPowderItem != null)
+ items.Add(customPowderItem);
+ }
+
return items;
}
@@ -130,6 +142,14 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
return;
}
+ if (itemDto.IsCustomFormulaItem && itemDto.ManualUnitPrice.HasValue)
+ {
+ item.UnitPrice = itemDto.ManualUnitPrice.Value;
+ item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
+ _logger.LogInformation("Custom formula item (FixedRate) price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
+ return;
+ }
+
if (itemDto.CatalogItemId.HasValue)
{
if (itemDto.Coats != null && itemDto.Coats.Any())
@@ -161,9 +181,10 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
///
/// Builds entities for a single item, including per-coat pricing.
- /// If a coat has AddAsIncoming = true and references a catalog item but not an inventory
- /// item, an incoming is auto-created so the shop can track the powder
- /// order and receive it later — see for details.
+ /// When a coat references the platform catalog (CatalogItemId set), the ID is stored on
+ /// so that at approval time the system
+ /// can create exactly one per unique powder across all coats on the
+ /// quote (deduplication). No inventory is created during quote save.
///
private async Task> BuildQuoteItemCoatsAsync(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
{
@@ -175,8 +196,8 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
{
var coatDto = itemDto.Coats[coatIndex];
- if (coatDto.AddAsIncoming && coatDto.CatalogItemId.HasValue && !coatDto.InventoryItemId.HasValue)
- coatDto.InventoryItemId = await CreateIncomingInventoryItemAsync(coatDto, companyId);
+ // Incoming-inventory creation is intentionally deferred to quote approval.
+ // PowderCatalogItemId is persisted on the coat entity for later use.
var coat = BuildQuoteItemCoat(coatDto, companyId, createdAtUtc);
var coatPricing = await _pricingService.CalculateCoatPriceAsync(
@@ -243,6 +264,9 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
IsAiItem = itemDto.IsAiItem,
AiTags = itemDto.AiTags,
AiPredictionId = itemDto.AiPredictionId,
+ IsCustomFormulaItem = itemDto.IsCustomFormulaItem,
+ CustomItemTemplateId = itemDto.CustomItemTemplateId,
+ FormulaFieldValuesJson = itemDto.FormulaFieldValuesJson,
CompanyId = companyId,
CreatedAt = createdAtUtc
};
@@ -256,6 +280,7 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
CoatName = coatDto.CoatName,
Sequence = coatDto.Sequence,
InventoryItemId = coatDto.InventoryItemId,
+ PowderCatalogItemId = coatDto.CatalogItemId,
ColorName = coatDto.ColorName,
VendorId = coatDto.VendorId,
ColorCode = coatDto.ColorCode,
@@ -305,34 +330,36 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
}
///
- /// Auto-creates an "incoming" when a user selects a powder from the
- /// platform catalog that doesn't yet exist in their company's inventory.
+ /// Creates one "incoming" from a platform catalog entry.
+ /// Called at quote-approval time (not during quote save) so inventory records only appear
+ /// when a job is actually going to be created. The caller groups coats by
+ /// PowderCatalogItemId and calls this once per unique catalog item, preventing
+ /// duplicate records when the same powder appears on multiple items in the same quote.
///
- /// WHY this exists: shops often quote jobs using powders they haven't ordered yet. Rather than
- /// forcing the user to manually add the powder to inventory before quoting, we create an
- /// IsIncoming=true record on their behalf. The shop can then receive the actual order against
- /// this record later (updating quantity + receive date) without losing the link to the original quote.
+ /// Category resolution prefers the company's "POWDER" category (CategoryCode=="POWDER")
+ /// so the item always lands in the right bucket regardless of how many IsCoating categories
+ /// the company has defined. Falls back to the lowest-DisplayOrder IsCoating category.
///
- /// The AI augmentation step (LookupByUrlAsync) fills in technical specs (cure temp/time, coverage,
- /// color families, etc.) that may be missing from the scraped catalog JSON. It is best-effort —
- /// if it fails, the item is still created with whatever data the catalog has.
- ///
- /// After creation, coatDto.PowderCostPerLb is cleared so the pricing engine treats this
- /// as an inventory-linked coat (not a custom powder), ensuring future repricings use the
- /// inventory unit cost rather than the now-stale manual price from the quote form.
+ /// AI augmentation fills in missing technical specs (cure temp/time, coverage, color families)
+ /// from the manufacturer product page. Best-effort — item is still created from catalog data
+ /// if the AI call fails.
///
- private async Task CreateIncomingInventoryItemAsync(CreateQuoteItemCoatDto coatDto, int companyId)
+ private async Task CreateIncomingInventoryItemAsync(int catalogItemId, int companyId)
{
try
{
- var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(coatDto.CatalogItemId!.Value);
+ var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(catalogItemId);
if (catalogItem == null) return null;
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
- var coatingCategory = categories
- .Where(c => c.IsActive && c.IsCoating)
- .OrderBy(c => c.DisplayOrder)
- .FirstOrDefault();
+ // Prefer the canonical "POWDER" category so catalog-sourced items never land in an
+ // unrelated coating category (e.g. "Cerakote") that happens to have IsCoating=true.
+ var coatingCategory = categories.FirstOrDefault(c => c.IsActive && c.IsCoating
+ && c.CategoryCode.Equals("POWDER", StringComparison.OrdinalIgnoreCase))
+ ?? categories
+ .Where(c => c.IsActive && c.IsCoating)
+ .OrderBy(c => c.DisplayOrder)
+ .FirstOrDefault();
var vendors = await _unitOfWork.Vendors.GetAllAsync();
var vendorNameLower = catalogItem.VendorName.ToLower();
@@ -437,17 +464,143 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
await _unitOfWork.InventoryItems.AddAsync(item);
await _unitOfWork.SaveChangesAsync();
- coatDto.PowderCostPerLb = null;
- _logger.LogInformation("Created incoming inventory item {Id} ({Name}) from catalog {CatalogId} via quote coat",
- item.Id, item.Name, coatDto.CatalogItemId);
+ _logger.LogInformation("Created incoming inventory item {Id} ({Name}) from catalog {CatalogId} at quote approval",
+ item.Id, item.Name, catalogItemId);
return item.Id;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to create incoming inventory item from catalog {CatalogId}, continuing without inventory link",
- coatDto.CatalogItemId);
+ catalogItemId);
return null;
}
}
+
+ ///
+ /// Scans all coat DTOs for powder that must be ordered (custom or catalog-sourced) and returns a
+ /// single "Custom Powder Order" QuoteItem aggregating all material costs and color names.
+ /// Returns null when no such coats are found. Used by
+ /// on the first save only — Option B means the user owns the price after creation.
+ ///
+ /// Coat types that qualify:
+ /// - Custom powder: no InventoryItemId, manual PowderCostPerLb > 0 (user-entered)
+ /// - Catalog-sourced pending incoming: CatalogItemId set, no InventoryItemId, PowderCostPerLb
+ /// pre-filled from catalog unit price (inventory creation deferred to approval)
+ /// - Legacy path: InventoryItemId set and item.IsIncoming == true (pre-fix records)
+ ///
+ private async Task BuildCustomPowderOrderItemAsync(
+ IReadOnlyList itemDtos, int quoteId, int companyId, DateTime createdAtUtc)
+ {
+ var colorNames = new List();
+ decimal totalCost = 0m;
+
+ foreach (var itemDto in itemDtos)
+ {
+ if (itemDto.Coats == null) continue;
+ foreach (var coat in itemDto.Coats)
+ {
+ if (!coat.InventoryItemId.HasValue &&
+ coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0 &&
+ coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0)
+ {
+ // Custom powder (manual cost) or catalog-sourced incoming (cost pre-filled from catalog).
+ // Both arrive here the same way: PowderCostPerLb set, no inventory link yet.
+ totalCost += coat.PowderToOrder.Value * coat.PowderCostPerLb.Value;
+ if (!string.IsNullOrWhiteSpace(coat.ColorName))
+ colorNames.Add(coat.ColorName);
+ }
+ else if (coat.InventoryItemId.HasValue && coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
+ {
+ // Legacy path: inventory was already created (quotes saved before the deferred-creation fix).
+ // PowderCostPerLb was cleared on those coats so cost must come from inventory.
+ var invItem = await _unitOfWork.InventoryItems.GetByIdAsync(coat.InventoryItemId.Value);
+ if (invItem?.IsIncoming == true)
+ {
+ totalCost += coat.PowderToOrder.Value * invItem.UnitCost;
+ var colorName = !string.IsNullOrWhiteSpace(coat.ColorName) ? coat.ColorName : invItem.Name;
+ if (!string.IsNullOrWhiteSpace(colorName))
+ colorNames.Add(colorName);
+ }
+ }
+ }
+ }
+
+ if (totalCost <= 0) return null;
+
+ var uniqueColors = colorNames
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ var description = uniqueColors.Any()
+ ? $"Custom Powder Order ({string.Join(", ", uniqueColors)})"
+ : "Custom Powder Order";
+
+ return new QuoteItem
+ {
+ QuoteId = quoteId,
+ Description = description,
+ Quantity = 1,
+ IsGenericItem = true,
+ ManualUnitPrice = totalCost,
+ UnitPrice = totalCost,
+ TotalPrice = totalCost,
+ ItemMaterialCost = totalCost,
+ CompanyId = companyId,
+ CreatedAt = createdAtUtc,
+ Coats = [],
+ PrepServices = []
+ };
+ }
+
+ ///
+ /// Called at quote approval time to create exactly one per unique
+ /// powder catalog entry referenced across all coats on the quote, then links each coat to its
+ /// new (or existing) inventory record.
+ ///
+ /// WHY deferred: during quoting the job may never be approved, so creating inventory records at
+ /// quote-save time produces orphaned, never-ordered items. Deferring to approval ensures inventory
+ /// only reflects powders the shop is actually going to process.
+ ///
+ /// Deduplication: multiple items on the same quote that use the same catalog powder receive the
+ /// same InventoryItemId — no duplicate records are created.
+ ///
+ /// Idempotent: coats that already have an InventoryItemId are skipped, so calling this method
+ /// on an already-approved quote (e.g. retry after a transient error) is safe.
+ ///
+ public async Task EnsureIncomingInventoryForApprovedQuoteAsync(int quoteId, int companyId)
+ {
+ // Load all QuoteItems for this quote with their coats so we can inspect PowderCatalogItemId.
+ var quoteItems = await _unitOfWork.QuoteItems.FindAsync(
+ qi => qi.QuoteId == quoteId && qi.CompanyId == companyId,
+ false,
+ qi => qi.Coats);
+
+ var pendingCoats = quoteItems
+ .SelectMany(qi => qi.Coats)
+ .Where(c => c.PowderCatalogItemId.HasValue && !c.InventoryItemId.HasValue)
+ .ToList();
+
+ if (pendingCoats.Count == 0) return;
+
+ // Group by catalog item ID so each unique powder generates exactly one inventory record.
+ var groups = pendingCoats
+ .GroupBy(c => c.PowderCatalogItemId!.Value)
+ .ToList();
+
+ foreach (var group in groups)
+ {
+ var newInventoryId = await CreateIncomingInventoryItemAsync(group.Key, companyId);
+ if (newInventoryId == null) continue;
+
+ // Link every coat in this group to the single newly-created inventory record.
+ foreach (var coat in group)
+ {
+ coat.InventoryItemId = newInventoryId;
+ coat.UpdatedAt = DateTime.UtcNow;
+ await _unitOfWork.QuoteItemCoats.UpdateAsync(coat);
+ }
+ }
+
+ await _unitOfWork.SaveChangesAsync();
+ }
}
diff --git a/src/PowderCoating.Core/Entities/ApplicationUser.cs b/src/PowderCoating.Core/Entities/ApplicationUser.cs
index d9500e6..b1f7521 100644
--- a/src/PowderCoating.Core/Entities/ApplicationUser.cs
+++ b/src/PowderCoating.Core/Entities/ApplicationUser.cs
@@ -73,6 +73,9 @@ public class ApplicationUser : IdentityUser
// Passkey enrollment prompt
public bool PasskeyPromptDismissed { get; set; } = false;
+ /// BCrypt hash of the employee's 4-digit kiosk PIN. Null means kiosk timeclock is disabled for this user.
+ public string? KioskPin { get; set; }
+
// Ban
public bool IsBanned { get; set; } = false;
public DateTime? BannedAt { get; set; }
diff --git a/src/PowderCoating.Core/Entities/Company.cs b/src/PowderCoating.Core/Entities/Company.cs
index 8d74c17..9a533cb 100644
--- a/src/PowderCoating.Core/Entities/Company.cs
+++ b/src/PowderCoating.Core/Entities/Company.cs
@@ -133,6 +133,15 @@ public class Company : BaseEntity
///
public string? KioskActivationToken { get; set; }
+ /// Timeclock feature enabled for this company. When false, the nav link, dashboard, and reports are hidden.
+ public bool TimeclockEnabled { get; set; } = true;
+
+ /// When true, employees can clock in/out multiple times per day (lunch breaks, etc.). When false, only one in/out pair is allowed per day.
+ public bool TimeclockAllowMultiplePunchesPerDay { get; set; } = true;
+
+ /// If set, any open clock entry older than this many hours is automatically closed on the next clock-in. Null = no auto clock-out.
+ public int? TimeclockAutoClockOutHours { get; set; }
+
// Navigation Properties
public virtual ICollection Users { get; set; } = new List();
public virtual ICollection Customers { get; set; } = new List();
diff --git a/src/PowderCoating.Core/Entities/CustomItemTemplate.cs b/src/PowderCoating.Core/Entities/CustomItemTemplate.cs
new file mode 100644
index 0000000..e8db2d4
--- /dev/null
+++ b/src/PowderCoating.Core/Entities/CustomItemTemplate.cs
@@ -0,0 +1,53 @@
+namespace PowderCoating.Core.Entities;
+
+///
+/// A per-company reusable pricing formula template. Users define named input fields and an
+/// NCalc expression that produces either a fixed dollar amount (FixedRate) or a surface area
+/// in square feet (SurfaceAreaSqFt) that feeds the standard coatings pricing path.
+///
+public class CustomItemTemplate : BaseEntity
+{
+ public string Name { get; set; } = string.Empty;
+ public string? Description { get; set; }
+
+ /// "FixedRate" or "SurfaceAreaSqFt" — controls which pricing path is used after evaluation.
+ public string OutputMode { get; set; } = "FixedRate";
+
+ /// JSON array of field definitions: [{name, label, unit, defaultValue}]
+ public string FieldsJson { get; set; } = "[]";
+
+ /// NCalc expression using field name slugs and the reserved variable 'rate'.
+ public string Formula { get; set; } = string.Empty;
+
+ /// Default rate value populated into the quote wizard; user can override per quote.
+ public decimal? DefaultRate { get; set; }
+
+ /// Display label for the rate field, e.g. "$/sq in" or "$/lb".
+ public string? RateLabel { get; set; }
+
+ public string? Notes { get; set; }
+ public int DisplayOrder { get; set; }
+ public bool IsActive { get; set; } = true;
+
+ ///
+ /// Optional reference diagram (shop drawing, sketch) stored in blob storage.
+ /// Shown in the template editor and quote wizard so users know which measurements to take.
+ /// Path format: {companyId}/{templateId}/diagram.{ext}
+ ///
+ public string? DiagramImagePath { get; set; }
+
+ // ── Community library tracking ─────────────────────────────────────────
+
+ ///
+ /// Set when this template was imported from the community library.
+ /// Null for originally created templates.
+ ///
+ public int? SourceFormulaLibraryItemId { get; set; }
+ public virtual FormulaLibraryItem? SourceFormulaLibraryItem { get; set; }
+
+ ///
+ /// True once the user edits an imported template. Only modified imports (and original
+ /// creations) are eligible to be shared back to the community library.
+ ///
+ public bool IsModifiedFromSource { get; set; }
+}
diff --git a/src/PowderCoating.Core/Entities/EmployeeClockEntry.cs b/src/PowderCoating.Core/Entities/EmployeeClockEntry.cs
new file mode 100644
index 0000000..91079cf
--- /dev/null
+++ b/src/PowderCoating.Core/Entities/EmployeeClockEntry.cs
@@ -0,0 +1,32 @@
+using PowderCoating.Core.Enums;
+
+namespace PowderCoating.Core.Entities;
+
+///
+/// Facility-level clock-in/clock-out record for an employee.
+/// Tracks when an employee arrives and leaves the facility — separate from JobTimeEntry which tracks
+/// hours against a specific job. Multiple entries per day are fully supported (lunch breaks, etc.).
+/// The only enforced constraint: a user may not have more than one open entry (ClockOutTime == null) at a time.
+///
+public class EmployeeClockEntry : BaseEntity
+{
+ public string UserId { get; set; } = string.Empty;
+
+ public DateTime ClockInTime { get; set; }
+
+ /// Null means the employee is currently clocked in.
+ public DateTime? ClockOutTime { get; set; }
+
+ /// Stored at clock-out time: (ClockOutTime - ClockInTime) in hours, rounded to 2 decimal places.
+ public decimal? HoursWorked { get; set; }
+
+ ///
+ /// Whether this segment is regular work time, a break, or a lunch period.
+ /// Only entries count toward paid-hours totals.
+ ///
+ public ClockEntryType EntryType { get; set; } = ClockEntryType.Work;
+
+ public string? Notes { get; set; }
+
+ public virtual ApplicationUser User { get; set; } = null!;
+}
diff --git a/src/PowderCoating.Core/Entities/FormulaLibraryImport.cs b/src/PowderCoating.Core/Entities/FormulaLibraryImport.cs
new file mode 100644
index 0000000..b065602
--- /dev/null
+++ b/src/PowderCoating.Core/Entities/FormulaLibraryImport.cs
@@ -0,0 +1,19 @@
+namespace PowderCoating.Core.Entities;
+
+///
+/// Records that a company imported a specific FormulaLibraryItem into their local template library.
+/// Tenant-scoped via BaseEntity.CompanyId. One row per (company, library item) — re-importing the
+/// same item overwrites the existing row rather than creating a duplicate.
+///
+public class FormulaLibraryImport : BaseEntity
+{
+ public int FormulaLibraryItemId { get; set; }
+ public virtual FormulaLibraryItem FormulaLibraryItem { get; set; } = null!;
+
+ public string ImportedByUserId { get; set; } = string.Empty;
+ public DateTime ImportedAt { get; set; } = DateTime.UtcNow;
+
+ /// The CustomItemTemplate row created in this company's local library on import.
+ public int ResultingCustomItemTemplateId { get; set; }
+ public virtual CustomItemTemplate ResultingCustomItemTemplate { get; set; } = null!;
+}
diff --git a/src/PowderCoating.Core/Entities/FormulaLibraryItem.cs b/src/PowderCoating.Core/Entities/FormulaLibraryItem.cs
new file mode 100644
index 0000000..06b2611
--- /dev/null
+++ b/src/PowderCoating.Core/Entities/FormulaLibraryItem.cs
@@ -0,0 +1,70 @@
+namespace PowderCoating.Core.Entities;
+
+///
+/// Platform-level community library entry for a shared custom formula template.
+/// Not tenant-scoped — no BaseEntity, no CompanyId, no soft delete.
+/// Shared voluntarily by the originating company; imported as independent copies by others.
+///
+public class FormulaLibraryItem
+{
+ public int Id { get; set; }
+
+ // ── Formula content (copied from CustomItemTemplate at share time) ─────
+
+ public string Name { get; set; } = string.Empty;
+ public string? Description { get; set; }
+
+ /// "FixedRate" or "SurfaceAreaSqFt" — mirrors CustomItemTemplate.OutputMode.
+ public string OutputMode { get; set; } = "FixedRate";
+
+ /// JSON array of field definitions: [{name, label, unit, defaultValue}]
+ public string FieldsJson { get; set; } = "[]";
+
+ /// NCalc expression using field name slugs and the reserved variable 'rate'.
+ public string Formula { get; set; } = string.Empty;
+
+ public decimal? DefaultRate { get; set; }
+ public string? RateLabel { get; set; }
+ public string? Notes { get; set; }
+
+ ///
+ /// Blob path referencing the source template's diagram image.
+ /// Nulled out (here and on all imports) if the source template's diagram is removed.
+ ///
+ public string? DiagramImagePath { get; set; }
+
+ // ── Attribution ────────────────────────────────────────────────────────
+
+ /// Comma-separated community tags, e.g. "HVAC,Sheet Metal".
+ public string? Tags { get; set; }
+
+ /// Optional industry hint shown on the browse card, e.g. "HVAC", "Automotive".
+ public string? IndustryHint { get; set; }
+
+ /// Id of the CustomItemTemplate this was shared from.
+ public int SourceCustomItemTemplateId { get; set; }
+
+ public int SourceCompanyId { get; set; }
+
+ /// Denormalized company name so it renders without a join when the company is gone.
+ public string SourceCompanyName { get; set; } = string.Empty;
+
+ ///
+ /// When non-null, this entry was derived from an imported formula that was subsequently
+ /// modified. Points to the original library entry. Shown as "Inspired by..." on the browse card.
+ ///
+ public int? InspiredByFormulaLibraryItemId { get; set; }
+ public virtual FormulaLibraryItem? InspiredBy { get; set; }
+
+ public string SharedByUserId { get; set; } = string.Empty;
+ public DateTime SharedAt { get; set; } = DateTime.UtcNow;
+
+ /// False when the creator has removed it from the community library.
+ public bool IsPublished { get; set; } = true;
+
+ /// Running count of how many companies have imported this entry.
+ public int ImportCount { get; set; }
+
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+ public DateTime? UpdatedAt { get; set; }
+}
diff --git a/src/PowderCoating.Core/Entities/FormulaLibraryRating.cs b/src/PowderCoating.Core/Entities/FormulaLibraryRating.cs
new file mode 100644
index 0000000..47e43a9
--- /dev/null
+++ b/src/PowderCoating.Core/Entities/FormulaLibraryRating.cs
@@ -0,0 +1,24 @@
+namespace PowderCoating.Core.Entities;
+
+///
+/// One thumbs-up or thumbs-down vote per company per library formula.
+/// Platform-level — no BaseEntity, no soft delete, no CompanyId tenant filter.
+/// Unique constraint enforced at the DB level: (FormulaLibraryItemId, CompanyId).
+///
+public class FormulaLibraryRating
+{
+ public int Id { get; set; }
+
+ public int FormulaLibraryItemId { get; set; }
+
+ /// The company casting the vote.
+ public int CompanyId { get; set; }
+
+ /// True = thumbs up, false = thumbs down.
+ public bool IsPositive { get; set; }
+
+ public DateTime RatedAt { get; set; } = DateTime.UtcNow;
+
+ // Navigation
+ public virtual FormulaLibraryItem FormulaLibraryItem { get; set; } = null!;
+}
diff --git a/src/PowderCoating.Core/Entities/JobItem.cs b/src/PowderCoating.Core/Entities/JobItem.cs
index 128ff19..7527733 100644
--- a/src/PowderCoating.Core/Entities/JobItem.cs
+++ b/src/PowderCoating.Core/Entities/JobItem.cs
@@ -52,6 +52,14 @@ public class JobItem : BaseEntity
public int? AiPredictionId { get; set; }
public virtual AiItemPrediction? AiPrediction { get; set; }
+ // Custom formula item — see IsCustomFormulaItem routing in PricingCalculationService
+ public bool IsCustomFormulaItem { get; set; }
+ public int? CustomItemTemplateId { get; set; }
+ public virtual CustomItemTemplate? CustomItemTemplate { get; set; }
+
+ /// Snapshot of field name/value pairs used in the formula, stored as JSON for display on details views.
+ public string? FormulaFieldValuesJson { get; set; }
+
// Relationships
public virtual Job Job { get; set; } = null!;
public virtual CatalogItem? CatalogItem { get; set; }
diff --git a/src/PowderCoating.Core/Entities/QuoteItem.cs b/src/PowderCoating.Core/Entities/QuoteItem.cs
index 5c96706..25fae48 100644
--- a/src/PowderCoating.Core/Entities/QuoteItem.cs
+++ b/src/PowderCoating.Core/Entities/QuoteItem.cs
@@ -56,6 +56,14 @@ public class QuoteItem : BaseEntity
public int? AiPredictionId { get; set; }
public virtual AiItemPrediction? AiPrediction { get; set; }
+ // Custom formula item — see IsCustomFormulaItem routing in PricingCalculationService
+ public bool IsCustomFormulaItem { get; set; }
+ public int? CustomItemTemplateId { get; set; }
+ public virtual CustomItemTemplate? CustomItemTemplate { get; set; }
+
+ /// Snapshot of field name/value pairs used in the formula, stored as JSON for display on details views.
+ public string? FormulaFieldValuesJson { get; set; }
+
// Relationships
public virtual Quote Quote { get; set; } = null!;
public virtual CatalogItem? CatalogItem { get; set; }
diff --git a/src/PowderCoating.Core/Entities/QuoteItemCoat.cs b/src/PowderCoating.Core/Entities/QuoteItemCoat.cs
index 3609448..7c3e8f7 100644
--- a/src/PowderCoating.Core/Entities/QuoteItemCoat.cs
+++ b/src/PowderCoating.Core/Entities/QuoteItemCoat.cs
@@ -15,6 +15,13 @@ public class QuoteItemCoat : BaseEntity
// Powder selection (same pattern as current QuoteItem)
public int? InventoryItemId { get; set; } // In-stock powder
+ ///
+ /// Platform powder catalog item that this coat was sourced from.
+ /// Persisted so that at quote-approval time the system can create exactly one
+ /// IsIncoming InventoryItem per unique catalog powder (deduplication), rather
+ /// than creating during quote-save when the job may never be approved.
+ ///
+ public int? PowderCatalogItemId { get; set; }
public string? ColorName { get; set; } // Color name
public int? VendorId { get; set; } // Vendor for custom powder
public string? ColorCode { get; set; } // RAL code, etc.
diff --git a/src/PowderCoating.Core/Entities/SubscriptionPlanConfig.cs b/src/PowderCoating.Core/Entities/SubscriptionPlanConfig.cs
index 4d46d07..3977f97 100644
--- a/src/PowderCoating.Core/Entities/SubscriptionPlanConfig.cs
+++ b/src/PowderCoating.Core/Entities/SubscriptionPlanConfig.cs
@@ -52,6 +52,9 @@ public class SubscriptionPlanConfig : BaseEntity
/// 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;
+ /// When true, companies on this plan can create and use Custom Formula Item Templates in quotes and jobs.
+ public bool AllowCustomFormulas { get; set; } = false;
+
public bool IsActive { get; set; } = true;
public int SortOrder { get; set; }
}
diff --git a/src/PowderCoating.Core/Entities/TimeclockKioskDevice.cs b/src/PowderCoating.Core/Entities/TimeclockKioskDevice.cs
new file mode 100644
index 0000000..0d3c630
--- /dev/null
+++ b/src/PowderCoating.Core/Entities/TimeclockKioskDevice.cs
@@ -0,0 +1,22 @@
+namespace PowderCoating.Core.Entities;
+
+///
+/// Represents an activated shop-floor kiosk tablet for the timeclock.
+/// One row per device; multiple rows per company are supported so shops can have
+/// tablets at multiple entry points. The is stored in a
+/// device-specific cookie and validated on every kiosk request.
+///
+public class TimeclockKioskDevice : BaseEntity
+{
+ /// Human-readable label for this device (e.g. "Front Entrance Tablet").
+ public string? DeviceName { get; set; }
+
+ /// Cryptographically random token written to the device cookie on activation. Revoke by deleting this row.
+ public string Token { get; set; } = string.Empty;
+
+ /// UTC timestamp when a manager activated this device.
+ public DateTime ActivatedAt { get; set; }
+
+ /// UTC timestamp of the most recent kiosk request from this device; null if never used after activation.
+ public DateTime? LastSeenAt { get; set; }
+}
diff --git a/src/PowderCoating.Core/Enums/TimeclockEnums.cs b/src/PowderCoating.Core/Enums/TimeclockEnums.cs
new file mode 100644
index 0000000..5241ddf
--- /dev/null
+++ b/src/PowderCoating.Core/Enums/TimeclockEnums.cs
@@ -0,0 +1,17 @@
+namespace PowderCoating.Core.Enums;
+
+///
+/// Labels what kind of time a represents.
+/// Only segments count toward paid-hours totals; Break and Lunch are informational.
+///
+public enum ClockEntryType
+{
+ /// Normal productive work time (default).
+ Work = 0,
+
+ /// Short rest/break period — unpaid, excluded from hour totals.
+ Break = 1,
+
+ /// Meal/lunch period — unpaid, excluded from hour totals.
+ Lunch = 2
+}
diff --git a/src/PowderCoating.Core/Interfaces/IUnitOfWork.cs b/src/PowderCoating.Core/Interfaces/IUnitOfWork.cs
index 8b417a9..30ed30c 100644
--- a/src/PowderCoating.Core/Interfaces/IUnitOfWork.cs
+++ b/src/PowderCoating.Core/Interfaces/IUnitOfWork.cs
@@ -155,6 +155,18 @@ IRepository ReworkRecords { get; }
// Customer Intake Kiosk
IRepository KioskSessions { get; }
+ // Custom Formula Templates
+ IRepository CustomItemTemplates { get; }
+
+ // Formula Community Library
+ IPlainRepository FormulaLibrary { get; }
+ IRepository FormulaLibraryImports { get; }
+ IPlainRepository FormulaLibraryRatings { get; }
+
+ // Employee Timeclock
+ IRepository EmployeeClockEntries { get; }
+ IRepository TimeclockKioskDevices { get; }
+
Task SaveChangesAsync();
Task CompleteAsync(); // Alias for SaveChangesAsync
diff --git a/src/PowderCoating.Infrastructure/Data/ApplicationDbContext.cs b/src/PowderCoating.Infrastructure/Data/ApplicationDbContext.cs
index 079d430..9a6969b 100644
--- a/src/PowderCoating.Infrastructure/Data/ApplicationDbContext.cs
+++ b/src/PowderCoating.Infrastructure/Data/ApplicationDbContext.cs
@@ -289,6 +289,15 @@ public class ApplicationDbContext : IdentityDbContext, IDataPro
///
public DbSet PowderCatalogItems { get; set; }
+ /// Community library of shared formula templates. Platform-level, no tenant filter.
+ public DbSet FormulaLibraryItems { get; set; }
+
+ /// Per-company record of which community library formulas a company has imported.
+ public DbSet FormulaLibraryImports { get; set; }
+
+ /// Per-company thumbs-up / thumbs-down vote on community library formulas.
+ public DbSet FormulaLibraryRatings { get; set; }
+
/// User-submitted bug reports; tenant-filtered with soft delete.
public DbSet BugReports { get; set; }
/// File attachments for bug reports; soft-delete only (no tenant filter — access controlled via parent BugReport).
@@ -374,6 +383,17 @@ public class ApplicationDbContext : IdentityDbContext, IDataPro
/// Customer self-service intake sessions (walk-in tablet or remote email link); tenant-filtered with soft delete.
public DbSet KioskSessions { get; set; }
+ // Custom Formula Templates
+ /// Per-company reusable NCalc pricing formula templates; tenant-filtered with soft delete.
+ public DbSet CustomItemTemplates { get; set; }
+
+ // Employee Timeclock
+ /// Facility-level clock-in/clock-out entries per employee; tenant-filtered with soft delete. Multiple entries per day are supported (lunch breaks, etc.).
+ public DbSet EmployeeClockEntries { get; set; }
+
+ /// One row per activated kiosk tablet per company. Token stored in device cookie; delete row to revoke a device.
+ public DbSet TimeclockKioskDevices { get; set; }
+
///
/// Platform-wide audit log capturing who changed what and when, across all tenants.
/// No global query filter — SuperAdmin controllers query this directly.
@@ -767,6 +787,32 @@ modelBuilder.Entity().HasQueryFilter(e =>
.HasForeignKey(k => k.LinkedJobId)
.OnDelete(DeleteBehavior.SetNull);
+ // Custom Formula Templates — tenant-filtered + soft delete
+ modelBuilder.Entity().HasQueryFilter(e =>
+ !e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
+
+ // Employee Timeclock — tenant-filtered + soft delete
+ modelBuilder.Entity().HasQueryFilter(e =>
+ !e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
+ // FK to ApplicationUser: Restrict delete so removing a user doesn't erase attendance history.
+ // Use DeleteBehavior.Restrict rather than NoAction to surface a cleaner error in EF.
+ modelBuilder.Entity()
+ .HasOne(c => c.User)
+ .WithMany()
+ .HasForeignKey(c => c.UserId)
+ .OnDelete(DeleteBehavior.Restrict);
+ // Composite index for "who's clocked in today" and date-range attendance reports
+ modelBuilder.Entity()
+ .HasIndex(c => new { c.CompanyId, c.ClockInTime });
+
+ // Timeclock kiosk devices — one row per activated tablet per company
+ modelBuilder.Entity().HasQueryFilter(d =>
+ !d.IsDeleted && (IsPlatformAdmin || d.CompanyId == CurrentCompanyId));
+ modelBuilder.Entity()
+ .HasIndex(d => d.Token).IsUnique();
+ modelBuilder.Entity()
+ .HasIndex(d => d.CompanyId);
+
// Account self-referencing hierarchy
modelBuilder.Entity()
.HasOne(a => a.ParentAccount)
@@ -2037,6 +2083,61 @@ modelBuilder.Entity()
.HasIndex(c => new { c.CompanyId, c.MemoNumber })
.IsUnique()
.HasDatabaseName("IX_CreditMemos_CompanyId_MemoNumber");
+
+ // FormulaLibraryItem — platform-level, no tenant filter, no soft delete
+ // Self-referential "Inspired by" FK uses NoAction; cascade nullification handled in service.
+ modelBuilder.Entity()
+ .HasOne(f => f.InspiredBy)
+ .WithMany()
+ .HasForeignKey(f => f.InspiredByFormulaLibraryItemId)
+ .IsRequired(false)
+ .OnDelete(DeleteBehavior.NoAction);
+
+ modelBuilder.Entity()
+ .HasIndex(f => f.SourceCompanyId)
+ .HasDatabaseName("IX_FormulaLibraryItems_SourceCompanyId");
+
+ modelBuilder.Entity()
+ .HasIndex(f => f.IsPublished)
+ .HasDatabaseName("IX_FormulaLibraryItems_IsPublished");
+
+ // FormulaLibraryImport — tenant-scoped; unique per (CompanyId, FormulaLibraryItemId)
+ modelBuilder.Entity()
+ .HasOne(i => i.FormulaLibraryItem)
+ .WithMany()
+ .HasForeignKey(i => i.FormulaLibraryItemId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ modelBuilder.Entity()
+ .HasOne(i => i.ResultingCustomItemTemplate)
+ .WithMany()
+ .HasForeignKey(i => i.ResultingCustomItemTemplateId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ modelBuilder.Entity()
+ .HasIndex(i => new { i.CompanyId, i.FormulaLibraryItemId })
+ .IsUnique()
+ .HasDatabaseName("IX_FormulaLibraryImports_Company_Item");
+
+ // FormulaLibraryRating — platform-level; one vote per company per formula
+ modelBuilder.Entity()
+ .HasOne(r => r.FormulaLibraryItem)
+ .WithMany()
+ .HasForeignKey(r => r.FormulaLibraryItemId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ modelBuilder.Entity()
+ .HasIndex(r => new { r.FormulaLibraryItemId, r.CompanyId })
+ .IsUnique()
+ .HasDatabaseName("IX_FormulaLibraryRatings_Item_Company");
+
+ // CustomItemTemplate → FormulaLibraryItem (nullable; only set on imported templates)
+ modelBuilder.Entity()
+ .HasOne(t => t.SourceFormulaLibraryItem)
+ .WithMany()
+ .HasForeignKey(t => t.SourceFormulaLibraryItemId)
+ .IsRequired(false)
+ .OnDelete(DeleteBehavior.SetNull);
}
///
diff --git a/src/PowderCoating.Infrastructure/Migrations/20260523153055_AddCustomItemTemplates.Designer.cs b/src/PowderCoating.Infrastructure/Migrations/20260523153055_AddCustomItemTemplates.Designer.cs
new file mode 100644
index 0000000..fd2916e
--- /dev/null
+++ b/src/PowderCoating.Infrastructure/Migrations/20260523153055_AddCustomItemTemplates.Designer.cs
@@ -0,0 +1,10780 @@
+//
+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("20260523153055_AddCustomItemTemplates")]
+ partial class AddCustomItemTemplates
+ {
+ ///
+ 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.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("FriendlyName")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Xml")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("Id");
+
+ b.ToTable("DataProtectionKeys");
+ });
+
+ 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("CanManageAccounting")
+ .HasColumnType("bit");
+
+ b.Property("CanManageBills")
+ .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("LaborCostPerHour")
+ .HasColumnType("decimal(18,2)");
+
+ 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("ReminderSentAt")
+ .HasColumnType("datetime2");
+
+ 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