Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a44133a63 | |||
| 3b5511a703 | |||
| 8df37ca760 | |||
| 7239f55308 | |||
| 09e077897b | |||
| 051c86810e | |||
| 6721de91e4 | |||
| 226a6237a6 |
@@ -112,6 +112,7 @@ namespace PowderCoating.Application.DTOs.Company
|
||||
|
||||
// Labor Rates
|
||||
public decimal StandardLaborRate { get; set; }
|
||||
public decimal? LaborCostPerHour { get; set; }
|
||||
public decimal AdditionalCoatLaborPercent { get; set; }
|
||||
|
||||
// Equipment Operating Costs
|
||||
@@ -185,6 +186,10 @@ namespace PowderCoating.Application.DTOs.Company
|
||||
[Display(Name = "Standard Labor Rate ($/hr)")]
|
||||
public decimal StandardLaborRate { get; set; }
|
||||
|
||||
[Range(0, 10000, ErrorMessage = "Labor cost rate must be between 0 and 10,000")]
|
||||
[Display(Name = "Shop Labor Cost Rate ($/hr)")]
|
||||
public decimal? LaborCostPerHour { get; set; }
|
||||
|
||||
[Range(0, 100, ErrorMessage = "Additional coat labor percent must be between 0 and 100")]
|
||||
[Display(Name = "Additional Coat Labor (%)")]
|
||||
public decimal AdditionalCoatLaborPercent { get; set; } = 30m;
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
using CsvHelper.Configuration.Attributes;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.Import;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for importing shop workers from CSV files.
|
||||
/// Valid Role values: GeneralLabor, Sandblaster, Coater, Masker, QualityControl, OvenOperator, Supervisor, Maintenance
|
||||
/// </summary>
|
||||
public class ShopWorkerImportDto
|
||||
{
|
||||
[Name("Name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[Name("Role")]
|
||||
public string Role { get; set; } = "GeneralLabor";
|
||||
|
||||
[Name("Phone")]
|
||||
public string? Phone { get; set; }
|
||||
|
||||
[Name("Email")]
|
||||
public string? Email { get; set; }
|
||||
|
||||
[Name("IsActive")]
|
||||
public bool? IsActive { get; set; }
|
||||
|
||||
[Name("Notes")]
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
@@ -604,6 +604,11 @@ public class QuotePricingBreakdownDto
|
||||
|
||||
public decimal SubtotalBeforeDiscount { get; set; }
|
||||
|
||||
public decimal PricingTierDiscountAmount { get; set; }
|
||||
public decimal PricingTierDiscountPercent { get; set; }
|
||||
public decimal QuoteDiscountAmount { get; set; }
|
||||
public decimal QuoteDiscountPercent { get; set; }
|
||||
|
||||
public decimal DiscountAmount { get; set; }
|
||||
public decimal DiscountPercent { get; set; }
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.ShopWorker;
|
||||
|
||||
public class CreateShopWorkerDto
|
||||
{
|
||||
[Required(ErrorMessage = "Worker name is required")]
|
||||
[StringLength(100, ErrorMessage = "Name cannot exceed 100 characters")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[Required(ErrorMessage = "Role is required")]
|
||||
public ShopWorkerRole Role { get; set; } = ShopWorkerRole.GeneralLabor;
|
||||
|
||||
[Phone(ErrorMessage = "Invalid phone number format")]
|
||||
[StringLength(20, ErrorMessage = "Phone cannot exceed 20 characters")]
|
||||
public string? Phone { get; set; }
|
||||
|
||||
[EmailAddress(ErrorMessage = "Invalid email address format")]
|
||||
[StringLength(100, ErrorMessage = "Email cannot exceed 100 characters")]
|
||||
public string? Email { get; set; }
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
[StringLength(500, ErrorMessage = "Notes cannot exceed 500 characters")]
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.ShopWorker;
|
||||
|
||||
public class ShopWorkerDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public ShopWorkerRole Role { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.ShopWorker;
|
||||
|
||||
public class UpdateShopWorkerDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "Worker name is required")]
|
||||
[StringLength(100, ErrorMessage = "Name cannot exceed 100 characters")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[Required(ErrorMessage = "Role is required")]
|
||||
public ShopWorkerRole Role { get; set; }
|
||||
|
||||
[Phone(ErrorMessage = "Invalid phone number format")]
|
||||
[StringLength(20, ErrorMessage = "Phone cannot exceed 20 characters")]
|
||||
public string? Phone { get; set; }
|
||||
|
||||
[EmailAddress(ErrorMessage = "Invalid email address format")]
|
||||
[StringLength(100, ErrorMessage = "Email cannot exceed 100 characters")]
|
||||
public string? Email { get; set; }
|
||||
|
||||
public bool IsActive { get; set; }
|
||||
|
||||
[StringLength(500, ErrorMessage = "Notes cannot exceed 500 characters")]
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
@@ -217,6 +217,10 @@ public class UpdateCompanyUserDto
|
||||
[Display(Name = "Active")]
|
||||
public bool IsActive { get; set; }
|
||||
|
||||
[Range(0, 10000, ErrorMessage = "Labor cost rate must be between 0 and 10,000")]
|
||||
[Display(Name = "Labor Cost Rate ($/hr)")]
|
||||
public decimal? LaborCostPerHour { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "Hire date is required")]
|
||||
[Display(Name = "Hire Date")]
|
||||
public DateTime HireDate { get; set; }
|
||||
|
||||
@@ -136,18 +136,7 @@ public interface ICsvImportService
|
||||
/// </summary>
|
||||
Task<CsvImportResultDto> ImportVendorsAsync(Stream csvStream, int companyId);
|
||||
|
||||
/// <summary>
|
||||
/// Generate a CSV template file for shop worker imports.
|
||||
/// </summary>
|
||||
byte[] GenerateShopWorkerTemplate();
|
||||
|
||||
/// <summary>
|
||||
/// Import shop workers from a CSV stream.
|
||||
/// Updates existing workers matched by Name; creates new ones otherwise.
|
||||
/// </summary>
|
||||
Task<CsvImportResultDto> ImportShopWorkersAsync(Stream csvStream, int companyId);
|
||||
|
||||
/// <summary>
|
||||
/// <summary>
|
||||
/// Generate a CSV template file for prep service imports.
|
||||
/// </summary>
|
||||
byte[] GeneratePrepServiceTemplate();
|
||||
|
||||
@@ -73,7 +73,7 @@ public class JobProfile : Profile
|
||||
// JobTimeEntry → JobTimeEntryDto
|
||||
CreateMap<JobTimeEntry, JobTimeEntryDto>()
|
||||
.ForMember(dest => dest.WorkerName, opt => opt.MapFrom(src =>
|
||||
src.UserDisplayName ?? (src.Worker != null ? src.Worker.Name : string.Empty)));
|
||||
src.UserDisplayName ?? string.Empty));
|
||||
|
||||
// CreateJobDto to Job
|
||||
CreateMap<CreateJobDto, Job>()
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
using AutoMapper;
|
||||
using PowderCoating.Application.DTOs.ShopWorker;
|
||||
using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Application.Mappings;
|
||||
|
||||
public class ShopWorkerProfile : Profile
|
||||
{
|
||||
public ShopWorkerProfile()
|
||||
{
|
||||
// Entity to DTO
|
||||
CreateMap<ShopWorker, ShopWorkerDto>();
|
||||
|
||||
// DTO to Entity
|
||||
CreateMap<CreateShopWorkerDto, ShopWorker>();
|
||||
CreateMap<UpdateShopWorkerDto, ShopWorker>();
|
||||
|
||||
// Reverse mappings
|
||||
CreateMap<ShopWorkerDto, ShopWorker>();
|
||||
CreateMap<ShopWorker, CreateShopWorkerDto>();
|
||||
CreateMap<ShopWorker, UpdateShopWorkerDto>();
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
PowderCostOverride = source.PowderCostOverride,
|
||||
UnitPrice = pricing.UnitPrice,
|
||||
TotalPrice = pricing.TotalPrice,
|
||||
LaborCost = pricing.TotalPrice * 0.4m,
|
||||
LaborCost = pricing.LaborCost,
|
||||
RequiresSandblasting = source.RequiresSandblasting,
|
||||
RequiresMasking = source.RequiresMasking,
|
||||
EstimatedMinutes = source.EstimatedMinutes,
|
||||
@@ -113,7 +113,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
PowderCostOverride = source.PowderCostOverride,
|
||||
UnitPrice = source.UnitPrice,
|
||||
TotalPrice = source.TotalPrice,
|
||||
LaborCost = source.TotalPrice * 0.4m,
|
||||
LaborCost = source.ItemLaborCost,
|
||||
RequiresSandblasting = source.RequiresSandblasting,
|
||||
RequiresMasking = source.RequiresMasking,
|
||||
EstimatedMinutes = source.EstimatedMinutes,
|
||||
|
||||
@@ -30,23 +30,30 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
ArgumentNullException.ThrowIfNull(quote);
|
||||
ArgumentNullException.ThrowIfNull(pricingResult);
|
||||
|
||||
quote.MaterialCosts = pricingResult.MaterialCosts;
|
||||
quote.LaborCosts = pricingResult.LaborCosts;
|
||||
quote.EquipmentCosts = pricingResult.EquipmentCosts;
|
||||
quote.ItemsSubtotal = pricingResult.ItemsSubtotal;
|
||||
quote.OvenBatchCost = pricingResult.OvenBatchCost;
|
||||
quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount;
|
||||
quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent;
|
||||
quote.OverheadAmount = pricingResult.OverheadCosts;
|
||||
quote.OverheadPercent = pricingResult.OverheadPercent;
|
||||
quote.ProfitMargin = pricingResult.ProfitMargin;
|
||||
quote.ProfitPercent = pricingResult.ProfitPercent;
|
||||
quote.SubTotal = pricingResult.SubtotalBeforeDiscount;
|
||||
quote.DiscountPercent = pricingResult.DiscountPercent;
|
||||
quote.DiscountAmount = pricingResult.DiscountAmount;
|
||||
quote.RushFee = pricingResult.RushFee;
|
||||
quote.TaxAmount = pricingResult.TaxAmount;
|
||||
quote.Total = pricingResult.Total;
|
||||
quote.MaterialCosts = pricingResult.MaterialCosts;
|
||||
quote.LaborCosts = pricingResult.LaborCosts;
|
||||
quote.EquipmentCosts = pricingResult.EquipmentCosts;
|
||||
quote.ItemsSubtotal = pricingResult.ItemsSubtotal;
|
||||
quote.OvenBatchCost = pricingResult.OvenBatchCost;
|
||||
quote.FacilityOverheadCost = pricingResult.FacilityOverheadCost;
|
||||
quote.FacilityOverheadRatePerHour = pricingResult.FacilityOverheadRatePerHour;
|
||||
quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount;
|
||||
quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent;
|
||||
quote.OverheadAmount = pricingResult.OverheadCosts;
|
||||
quote.OverheadPercent = pricingResult.OverheadPercent;
|
||||
quote.ProfitMargin = pricingResult.ProfitMargin;
|
||||
quote.ProfitPercent = pricingResult.ProfitPercent;
|
||||
quote.SubTotal = pricingResult.SubtotalBeforeDiscount;
|
||||
quote.PricingTierDiscountAmount = pricingResult.PricingTierDiscountAmount;
|
||||
quote.PricingTierDiscountPercent = pricingResult.PricingTierDiscountPercent;
|
||||
quote.QuoteDiscountAmount = pricingResult.QuoteDiscountAmount;
|
||||
quote.QuoteDiscountPercent = pricingResult.QuoteDiscountPercent;
|
||||
quote.DiscountPercent = pricingResult.DiscountPercent;
|
||||
quote.DiscountAmount = pricingResult.DiscountAmount;
|
||||
quote.SubtotalAfterDiscount = pricingResult.SubtotalAfterDiscount;
|
||||
quote.RushFee = pricingResult.RushFee;
|
||||
quote.TaxAmount = pricingResult.TaxAmount;
|
||||
quote.Total = pricingResult.Total;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<QuoteItem>> CreateQuoteItemsAsync(
|
||||
|
||||
@@ -58,7 +58,14 @@ public class ApplicationUser : IdentityUser
|
||||
|
||||
public string? SidebarColor { get; set; } = "ocean";
|
||||
public string? Notes { get; set; }
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Per-worker labor cost per hour used for job costing profit/margin calculations.
|
||||
/// Overrides the company-level LaborCostPerHour when set.
|
||||
/// Leave null to use the company default.
|
||||
/// </summary>
|
||||
public decimal? LaborCostPerHour { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public DateTime? LastLoginDate { get; set; }
|
||||
|
||||
@@ -141,8 +141,7 @@ public class Company : BaseEntity
|
||||
public virtual ICollection<Quote> Quotes { get; set; } = new List<Quote>();
|
||||
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
|
||||
public virtual ICollection<Vendor> Vendors { get; set; } = new List<Vendor>();
|
||||
public virtual ICollection<ShopWorker> ShopWorkers { get; set; } = new List<ShopWorker>();
|
||||
public virtual ICollection<PricingTier> PricingTiers { get; set; } = new List<PricingTier>();
|
||||
public virtual ICollection<PricingTier> PricingTiers { get; set; } = new List<PricingTier>();
|
||||
public virtual CompanyOperatingCosts? OperatingCosts { get; set; }
|
||||
public virtual CompanyPreferences? Preferences { get; set; }
|
||||
}
|
||||
|
||||
@@ -13,6 +13,14 @@ namespace PowderCoating.Core.Entities
|
||||
[Range(0, 10000)]
|
||||
public decimal StandardLaborRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Actual labor cost per hour (wages + burden) used exclusively for internal job costing and profit/margin display.
|
||||
/// This is NOT the billing rate — it should reflect what you actually pay workers.
|
||||
/// When null, the costing engine defaults to 20% of StandardLaborRate.
|
||||
/// </summary>
|
||||
[Range(0, 10000)]
|
||||
public decimal? LaborCostPerHour { get; set; }
|
||||
|
||||
// Additional Coat Labor Percentage (percentage of base labor for each additional coat beyond the first)
|
||||
[Range(0, 100)]
|
||||
public decimal AdditionalCoatLaborPercent { get; set; } = 30m;
|
||||
|
||||
@@ -66,6 +66,10 @@ public class Job : BaseEntity
|
||||
// Used to detect when the quote was subsequently edited so the job details page can warn the user.
|
||||
public DateTime? QuoteSnapshotUpdatedAt { get; set; }
|
||||
|
||||
// Pricing snapshot — serialized QuotePricingBreakdownDto stored at save time so Details displays
|
||||
// the breakdown that was actually calculated, not a re-run against current operating costs.
|
||||
public string? PricingBreakdownJson { get; set; }
|
||||
|
||||
// Rework tracking
|
||||
public bool IsReworkJob { get; set; }
|
||||
public int? OriginalJobId { get; set; } // Set when this job was created as a rework
|
||||
|
||||
@@ -3,7 +3,6 @@ namespace PowderCoating.Core.Entities;
|
||||
public class JobTimeEntry : BaseEntity
|
||||
{
|
||||
public int JobId { get; set; }
|
||||
public int? ShopWorkerId { get; set; } // legacy — kept for entries created before user migration
|
||||
public string? UserId { get; set; } // FK to AspNetUsers
|
||||
public string? UserDisplayName { get; set; } // snapshot of worker name at entry creation time
|
||||
public DateTime WorkDate { get; set; }
|
||||
@@ -13,5 +12,4 @@ public class JobTimeEntry : BaseEntity
|
||||
|
||||
// Navigation
|
||||
public virtual Job Job { get; set; } = null!;
|
||||
public virtual ShopWorker? Worker { get; set; } // nullable — only populated for legacy entries
|
||||
}
|
||||
|
||||
@@ -40,26 +40,33 @@ public class Quote : BaseEntity
|
||||
public DateTime? ApprovedDate { get; set; }
|
||||
|
||||
// Pricing — all values are snapshots captured at save time and must not be recalculated on load
|
||||
public decimal MaterialCosts { get; set; } // Sum of powder/material costs across all items
|
||||
public decimal LaborCosts { get; set; } // Sum of labor costs across all items
|
||||
public decimal EquipmentCosts { get; set; } // Sum of equipment costs across all items
|
||||
public decimal ItemsSubtotal { get; set; } // Sum of item prices before any quote-level costs
|
||||
public decimal OvenBatchCost { get; set; } // Oven batch charge applied at quote level
|
||||
public decimal ShopSuppliesAmount { get; set; } // Shop supplies dollar amount
|
||||
public decimal ShopSuppliesPercent { get; set; } // Shop supplies percentage used
|
||||
public decimal OverheadAmount { get; set; } // Overhead dollar amount
|
||||
public decimal OverheadPercent { get; set; } // Overhead percentage used
|
||||
public decimal ProfitMargin { get; set; } // Profit margin dollar amount
|
||||
public decimal ProfitPercent { get; set; } // Profit margin percentage used
|
||||
public decimal SubTotal { get; set; } // SubtotalBeforeDiscount (items + oven + overhead + profit + shop supplies)
|
||||
public decimal MaterialCosts { get; set; } // Sum of powder/material costs across all items
|
||||
public decimal LaborCosts { get; set; } // Sum of labor costs across all items
|
||||
public decimal EquipmentCosts { get; set; } // Sum of equipment costs across all items
|
||||
public decimal ItemsSubtotal { get; set; } // Sum of item prices before any quote-level costs
|
||||
public decimal OvenBatchCost { get; set; } // Oven batch charge applied at quote level
|
||||
public decimal FacilityOverheadCost { get; set; } // Rent + utilities apportioned by estimated job hours
|
||||
public decimal FacilityOverheadRatePerHour { get; set; }// Rate used for facility overhead ($/hr)
|
||||
public decimal ShopSuppliesAmount { get; set; } // Shop supplies dollar amount
|
||||
public decimal ShopSuppliesPercent { get; set; } // Shop supplies percentage used
|
||||
public decimal OverheadAmount { get; set; } // Legacy overhead (now always 0; kept for migration safety)
|
||||
public decimal OverheadPercent { get; set; } // Legacy overhead percent
|
||||
public decimal ProfitMargin { get; set; } // Profit margin dollar amount (0 — baked into item prices)
|
||||
public decimal ProfitPercent { get; set; } // Markup % used (for display reference)
|
||||
public decimal SubTotal { get; set; } // SubtotalBeforeDiscount (items + oven + facility overhead + shop supplies)
|
||||
|
||||
// Discount Information
|
||||
public DiscountType DiscountType { get; set; } = DiscountType.None;
|
||||
public decimal DiscountValue { get; set; } = 0; // Value entered by user (percentage or fixed amount)
|
||||
public decimal DiscountPercent { get; set; } // Calculated: actual percentage applied
|
||||
public decimal DiscountAmount { get; set; } // Calculated: actual dollar amount deducted
|
||||
public string? DiscountReason { get; set; } // Why discount was applied
|
||||
public decimal DiscountValue { get; set; } = 0; // Value entered by user (percentage or fixed amount)
|
||||
public decimal PricingTierDiscountAmount { get; set; } // Discount from customer's pricing tier
|
||||
public decimal PricingTierDiscountPercent { get; set; } // Tier discount percentage
|
||||
public decimal QuoteDiscountAmount { get; set; } // Manual quote-level discount amount
|
||||
public decimal QuoteDiscountPercent { get; set; } // Manual quote-level discount percentage
|
||||
public decimal DiscountPercent { get; set; } // Combined: actual percentage applied
|
||||
public decimal DiscountAmount { get; set; } // Combined: actual dollar amount deducted
|
||||
public string? DiscountReason { get; set; } // Why discount was applied
|
||||
public bool HideDiscountFromCustomer { get; set; } = false; // Show only total on PDFs/portal
|
||||
public decimal SubtotalAfterDiscount { get; set; } // SubTotal minus all discounts, before rush/tax
|
||||
|
||||
public decimal TaxPercent { get; set; }
|
||||
public decimal TaxAmount { get; set; }
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Core.Entities;
|
||||
|
||||
public class ShopWorker : BaseEntity
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public ShopWorkerRole Role { get; set; } = ShopWorkerRole.GeneralLabor;
|
||||
public string? Phone { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public string? Notes { get; set; }
|
||||
|
||||
// Relationships
|
||||
public virtual ICollection<Job> AssignedJobs { get; set; } = new List<Job>();
|
||||
public virtual ICollection<MaintenanceRecord> AssignedMaintenanceTasks { get; set; } = new List<MaintenanceRecord>();
|
||||
public virtual ICollection<JobTimeEntry> TimeEntries { get; set; } = new List<JobTimeEntry>();
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Optional per-role labor cost rate for job costing / profitability calculations.
|
||||
/// If no rate is set for a role, the company's StandardLaborRate is used as fallback.
|
||||
/// </summary>
|
||||
public class ShopWorkerRoleCost : BaseEntity
|
||||
{
|
||||
public ShopWorkerRole Role { get; set; }
|
||||
|
||||
/// <summary>Cost (pay rate) per hour for this role — used in job costing, NOT billing.</summary>
|
||||
public decimal HourlyRate { get; set; }
|
||||
}
|
||||
@@ -78,17 +78,6 @@ public enum EquipmentStatus
|
||||
Retired = 4
|
||||
}
|
||||
|
||||
public enum ShopWorkerRole
|
||||
{
|
||||
GeneralLabor = 0,
|
||||
Sandblaster = 1,
|
||||
Coater = 2,
|
||||
Masker = 3,
|
||||
QualityControl = 4,
|
||||
OvenOperator = 5,
|
||||
Supervisor = 6,
|
||||
Maintenance = 7
|
||||
}
|
||||
|
||||
public enum JobPhotoType
|
||||
{
|
||||
|
||||
@@ -54,9 +54,7 @@ public interface IUnitOfWork : IDisposable
|
||||
IRepository<AppointmentStatusLookup> AppointmentStatusLookups { get; }
|
||||
IRepository<AppointmentTypeLookup> AppointmentTypeLookups { get; }
|
||||
IRepository<PrepService> PrepServices { get; }
|
||||
IRepository<ShopWorker> ShopWorkers { get; }
|
||||
IRepository<ShopWorkerRoleCost> ShopWorkerRoleCosts { get; }
|
||||
IRepository<ReworkRecord> ReworkRecords { get; }
|
||||
IRepository<ReworkRecord> ReworkRecords { get; }
|
||||
IRepository<Refund> Refunds { get; }
|
||||
IRepository<CreditMemo> CreditMemos { get; }
|
||||
IRepository<CreditMemoApplication> CreditMemoApplications { get; }
|
||||
|
||||
@@ -205,11 +205,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
public DbSet<MaintenanceRecord> MaintenanceRecords { get; set; }
|
||||
/// <summary>Supplier/vendor records used by Purchasing and Accounts Payable; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<Vendor> Vendors { get; set; }
|
||||
/// <summary>Shop worker profiles with role assignments; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<ShopWorker> ShopWorkers { get; set; }
|
||||
/// <summary>Per-role labour cost rates used in pricing calculations; unique index on (CompanyId, Role).</summary>
|
||||
public DbSet<ShopWorkerRoleCost> ShopWorkerRoleCosts { get; set; }
|
||||
/// <summary>Rework records tracking quality failures and remediation work against a job; tenant-filtered with soft delete.</summary>
|
||||
/// <summary>Rework records tracking quality failures and remediation work against a job; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<ReworkRecord> ReworkRecords { get; set; }
|
||||
/// <summary>Customer refund records; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<Refund> Refunds { get; set; }
|
||||
@@ -530,11 +526,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<JobStatusHistory>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<ShopWorker>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<ShopWorkerRoleCost>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
||||
modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<Refund>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
@@ -1314,12 +1306,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
.HasForeignKey(m => m.PerformedById)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
// ShopWorker relationships
|
||||
modelBuilder.Entity<ShopWorker>()
|
||||
.HasOne<Company>()
|
||||
.WithMany(c => c.ShopWorkers)
|
||||
.HasForeignKey(e => e.CompanyId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
|
||||
modelBuilder.Entity<Job>()
|
||||
.HasOne(j => j.AssignedUser)
|
||||
@@ -1393,10 +1380,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
modelBuilder.Entity<PricingTier>()
|
||||
.HasIndex(p => p.CompanyId);
|
||||
|
||||
modelBuilder.Entity<ShopWorker>()
|
||||
.HasIndex(w => w.CompanyId);
|
||||
|
||||
modelBuilder.Entity<CatalogCategory>()
|
||||
modelBuilder.Entity<CatalogCategory>()
|
||||
.HasIndex(c => c.CompanyId);
|
||||
|
||||
modelBuilder.Entity<CatalogCategory>()
|
||||
@@ -1431,12 +1415,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_Jobs_CompanyId_JobNumber");
|
||||
|
||||
modelBuilder.Entity<ShopWorkerRoleCost>()
|
||||
.HasIndex(r => new { r.CompanyId, r.Role })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_ShopWorkerRoleCosts_CompanyId_Role");
|
||||
|
||||
modelBuilder.Entity<Job>()
|
||||
modelBuilder.Entity<Job>()
|
||||
.Property(j => j.ShopAccessCode)
|
||||
.HasDefaultValueSql("NEWID()");
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ public class AuditInterceptor : SaveChangesInterceptor
|
||||
private static readonly HashSet<string> AuditedTypes = new(StringComparer.Ordinal)
|
||||
{
|
||||
nameof(Customer), nameof(Job), nameof(Quote), nameof(Equipment),
|
||||
nameof(MaintenanceRecord), nameof(Vendor), nameof(ShopWorker),
|
||||
nameof(MaintenanceRecord), nameof(Vendor),
|
||||
nameof(InventoryItem), nameof(Company),
|
||||
// Financial entities
|
||||
nameof(Invoice), nameof(Payment), nameof(Bill), nameof(BillPayment),
|
||||
|
||||
Generated
+10757
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddJobPricingSnapshot : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PricingBreakdownJson",
|
||||
table: "Jobs",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4618));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4623));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4625));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PricingBreakdownJson",
|
||||
table: "Jobs");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2464));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2473));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2474));
|
||||
}
|
||||
}
|
||||
}
|
||||
src/PowderCoating.Infrastructure/Migrations/20260515194344_AddQuotePricingSnapshotFields.Designer.cs
Generated
+10778
File diff suppressed because it is too large
Load Diff
+138
@@ -0,0 +1,138 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddQuotePricingSnapshotFields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "FacilityOverheadCost",
|
||||
table: "Quotes",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "FacilityOverheadRatePerHour",
|
||||
table: "Quotes",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "PricingTierDiscountAmount",
|
||||
table: "Quotes",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "PricingTierDiscountPercent",
|
||||
table: "Quotes",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "QuoteDiscountAmount",
|
||||
table: "Quotes",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "QuoteDiscountPercent",
|
||||
table: "Quotes",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "SubtotalAfterDiscount",
|
||||
table: "Quotes",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(845));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(850));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(852));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FacilityOverheadCost",
|
||||
table: "Quotes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FacilityOverheadRatePerHour",
|
||||
table: "Quotes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PricingTierDiscountAmount",
|
||||
table: "Quotes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PricingTierDiscountPercent",
|
||||
table: "Quotes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "QuoteDiscountAmount",
|
||||
table: "Quotes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "QuoteDiscountPercent",
|
||||
table: "Quotes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SubtotalAfterDiscount",
|
||||
table: "Quotes");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4618));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4623));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4625));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10784
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddLaborCostPerHour : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "LaborCostPerHour",
|
||||
table: "CompanyOperatingCosts",
|
||||
type: "decimal(18,2)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "LaborCostPerHour",
|
||||
table: "AspNetUsers",
|
||||
type: "decimal(18,2)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3131));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3137));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3138));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LaborCostPerHour",
|
||||
table: "CompanyOperatingCosts");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LaborCostPerHour",
|
||||
table: "AspNetUsers");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(845));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(850));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(852));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -556,6 +556,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<bool>("IsBanned")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<decimal?>("LaborCostPerHour")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<DateTime?>("LastLoginDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
@@ -2075,6 +2078,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<decimal?>("LaborCostPerHour")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<int>("MonthlyBillableHours")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -4217,6 +4223,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<int?>("OvenCycleMinutes")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("PricingBreakdownJson")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("QuoteId")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -6711,7 +6720,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2464),
|
||||
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3131),
|
||||
Description = "Standard pricing for regular customers",
|
||||
DiscountPercent = 0m,
|
||||
IsActive = true,
|
||||
@@ -6722,7 +6731,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2473),
|
||||
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3137),
|
||||
Description = "5% discount for preferred customers",
|
||||
DiscountPercent = 5m,
|
||||
IsActive = true,
|
||||
@@ -6733,7 +6742,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2474),
|
||||
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3138),
|
||||
Description = "10% discount for premium customers",
|
||||
DiscountPercent = 10m,
|
||||
IsActive = true,
|
||||
@@ -6980,6 +6989,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<DateTime?>("ExpirationDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<decimal>("FacilityOverheadCost")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal>("FacilityOverheadRatePerHour")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<bool>("HideDiscountFromCustomer")
|
||||
.HasColumnType("bit");
|
||||
|
||||
@@ -7025,6 +7040,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("PreparedById")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<decimal>("PricingTierDiscountAmount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal>("PricingTierDiscountPercent")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal>("ProfitMargin")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
@@ -7064,6 +7085,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<DateTime>("QuoteDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<decimal>("QuoteDiscountAmount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal>("QuoteDiscountPercent")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("QuoteNumber")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(450)");
|
||||
@@ -7089,6 +7116,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<decimal>("SubTotal")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal>("SubtotalAfterDiscount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("Tags")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
|
||||
@@ -171,7 +171,6 @@ public class JobRepository : Repository<Job>, IJobRepository
|
||||
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
|
||||
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
|
||||
.Include(j => j.TimeEntries.Where(t => !t.IsDeleted))
|
||||
.ThenInclude(t => t.Worker)
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
@@ -81,7 +81,6 @@ public class UnitOfWork : IUnitOfWork
|
||||
private IRepository<AppointmentStatusLookup>? _appointmentStatusLookups;
|
||||
private IRepository<AppointmentTypeLookup>? _appointmentTypeLookups;
|
||||
private IRepository<PrepService>? _prepServices;
|
||||
private IRepository<ShopWorker>? _shopWorkers;
|
||||
|
||||
// Appointments
|
||||
private IRepository<Appointment>? _appointments;
|
||||
@@ -350,16 +349,7 @@ public class UnitOfWork : IUnitOfWork
|
||||
public IRepository<PrepService> PrepServices =>
|
||||
_prepServices ??= new Repository<PrepService>(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="ShopWorker"/> profiles with role assignments; tenant-filtered with soft delete.</summary>
|
||||
public IRepository<ShopWorker> ShopWorkers =>
|
||||
_shopWorkers ??= new Repository<ShopWorker>(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="ShopWorkerRoleCost"/> per-role labour cost rates; unique on (CompanyId, Role).</summary>
|
||||
private IRepository<ShopWorkerRoleCost>? _shopWorkerRoleCosts;
|
||||
public IRepository<ShopWorkerRoleCost> ShopWorkerRoleCosts =>
|
||||
_shopWorkerRoleCosts ??= new Repository<ShopWorkerRoleCost>(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="ReworkRecord"/> quality-failure and remediation records; tenant-filtered with soft delete.</summary>
|
||||
/// <summary>Repository for <see cref="ReworkRecord"/> quality-failure and remediation records; tenant-filtered with soft delete.</summary>
|
||||
private IRepository<ReworkRecord>? _reworkRecords;
|
||||
public IRepository<ReworkRecord> ReworkRecords =>
|
||||
_reworkRecords ??= new Repository<ReworkRecord>(_context);
|
||||
|
||||
@@ -73,7 +73,6 @@ public class CompanyDataPurgeService : ICompanyDataPurgeService
|
||||
await _context.NotificationTemplates.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.Announcements.Where(x => x.TargetCompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.BugReports.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.ShopWorkers.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.PrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
|
||||
// ── Tier 4: Company configs and lookup tables ─────────────────────────
|
||||
@@ -137,7 +136,6 @@ public class CompanyDataPurgeService : ICompanyDataPurgeService
|
||||
await _context.PurchaseOrderItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.AiItemPredictions.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.PowderUsageLogs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.ShopWorkerRoleCosts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.OvenBatches.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.Refunds.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.CreditMemos.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
@@ -160,7 +158,6 @@ public class CompanyDataPurgeService : ICompanyDataPurgeService
|
||||
await _context.OvenCosts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.Accounts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.NotificationLogs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.ShopWorkers.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.PrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.CatalogCategories.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.InventoryCategoryLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using CsvHelper;
|
||||
using CsvHelper.Configuration;
|
||||
@@ -2164,168 +2164,6 @@ public class CsvImportService : ICsvImportService
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Shop Worker Import
|
||||
|
||||
/// <summary>
|
||||
/// Generates a downloadable CSV template with two example shop worker rows covering different roles.
|
||||
/// Two rows help users see how Role values (Coater, Sandblaster, etc.) are expressed and remind
|
||||
/// them that Role is optional — the importer will default to GeneralLabor when it is omitted.
|
||||
/// </summary>
|
||||
public byte[] GenerateShopWorkerTemplate()
|
||||
{
|
||||
using var memoryStream = new MemoryStream();
|
||||
using var writer = new StreamWriter(memoryStream);
|
||||
using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture));
|
||||
|
||||
csv.WriteHeader<ShopWorkerImportDto>();
|
||||
csv.NextRecord();
|
||||
|
||||
csv.WriteRecord(new ShopWorkerImportDto
|
||||
{
|
||||
Name = "John Doe",
|
||||
Role = "Coater",
|
||||
Phone = "555-1234",
|
||||
Email = "johndoe@example.com",
|
||||
IsActive = true,
|
||||
Notes = "Experienced powder coater"
|
||||
});
|
||||
csv.NextRecord();
|
||||
|
||||
csv.WriteRecord(new ShopWorkerImportDto
|
||||
{
|
||||
Name = "Jane Smith",
|
||||
Role = "Sandblaster",
|
||||
Phone = "555-5678",
|
||||
Email = "janesmith@example.com",
|
||||
IsActive = true,
|
||||
Notes = ""
|
||||
});
|
||||
csv.NextRecord();
|
||||
|
||||
writer.Flush();
|
||||
return memoryStream.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports shop workers from a CSV stream using an upsert strategy keyed on worker Name.
|
||||
/// Like vendor import, this is intentionally an upsert rather than insert-only so that a
|
||||
/// company can re-import their HR list to update phone/email/role details without worrying
|
||||
/// about creating duplicates. Role is parsed case-insensitively with spaces stripped so that
|
||||
/// "General Labor" and "GeneralLabor" are both accepted; an unrecognised role falls back to
|
||||
/// GeneralLabor with a warning rather than failing the row.
|
||||
/// </summary>
|
||||
/// <param name="csvStream">Readable stream of CSV data (header row required).</param>
|
||||
/// <param name="companyId">Tenant company that will own newly inserted worker records.</param>
|
||||
public async Task<CsvImportResultDto> ImportShopWorkersAsync(Stream csvStream, int companyId)
|
||||
{
|
||||
var result = new CsvImportResultDto();
|
||||
var rowNumber = 0;
|
||||
|
||||
try
|
||||
{
|
||||
using var reader = new StreamReader(csvStream);
|
||||
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
|
||||
{
|
||||
HeaderValidated = null,
|
||||
MissingFieldFound = null
|
||||
});
|
||||
|
||||
var records = csv.GetRecords<ShopWorkerImportDto>().ToList();
|
||||
result.TotalRows = records.Count;
|
||||
|
||||
_logger.LogInformation("Starting import of {Count} shop workers for company {CompanyId}", records.Count, companyId);
|
||||
|
||||
// Load existing workers for upsert matching by name
|
||||
var existingWorkers = await _unitOfWork.ShopWorkers.GetAllAsync();
|
||||
var workerDict = existingWorkers
|
||||
.Where(w => !string.IsNullOrEmpty(w.Name))
|
||||
.GroupBy(w => w.Name.Trim().ToUpperInvariant())
|
||||
.ToDictionary(g => g.Key, g => g.First());
|
||||
|
||||
foreach (var record in records)
|
||||
{
|
||||
rowNumber++;
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(record.Name))
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: Name is required.");
|
||||
result.ErrorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse role
|
||||
ShopWorkerRole role = ShopWorkerRole.GeneralLabor;
|
||||
if (!string.IsNullOrEmpty(record.Role))
|
||||
{
|
||||
if (!Enum.TryParse<ShopWorkerRole>(record.Role.Replace(" ", ""), true, out role))
|
||||
{
|
||||
result.Warnings.Add($"Row {rowNumber}: Role '{record.Role}' not recognized. Valid values: GeneralLabor, Sandblaster, Coater, Masker, QualityControl, OvenOperator, Supervisor, Maintenance. Using 'GeneralLabor'.");
|
||||
role = ShopWorkerRole.GeneralLabor;
|
||||
}
|
||||
}
|
||||
|
||||
var key = record.Name.Trim().ToUpperInvariant();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
if (workerDict.TryGetValue(key, out var existing))
|
||||
{
|
||||
// Update
|
||||
existing.Role = role;
|
||||
existing.Phone = record.Phone ?? existing.Phone;
|
||||
existing.Email = record.Email ?? existing.Email;
|
||||
if (record.IsActive.HasValue) existing.IsActive = record.IsActive.Value;
|
||||
existing.Notes = record.Notes ?? existing.Notes;
|
||||
existing.UpdatedAt = now;
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
result.Warnings.Add($"Row {rowNumber}: Updated existing shop worker '{record.Name}'.");
|
||||
result.SuccessCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
var worker = new Core.Entities.ShopWorker
|
||||
{
|
||||
CompanyId = companyId,
|
||||
Name = record.Name.Trim(),
|
||||
Role = role,
|
||||
Phone = record.Phone,
|
||||
Email = record.Email,
|
||||
IsActive = record.IsActive ?? true,
|
||||
Notes = record.Notes,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
await _unitOfWork.ShopWorkers.AddAsync(worker);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
result.SuccessCount++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: Database error - {ex.Message}");
|
||||
result.ErrorCount++;
|
||||
_logger.LogError(ex, "Error saving shop worker at row {RowNumber}", rowNumber);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Shop worker import completed: {SuccessCount} succeeded, {ErrorCount} failed", result.SuccessCount, result.ErrorCount);
|
||||
result.Success = result.SuccessCount > 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Errors.Add($"Fatal error: {ex.Message}");
|
||||
result.Success = false;
|
||||
_logger.LogError(ex, "Fatal error importing shop workers");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Prep Service Import
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -133,7 +133,6 @@ public class AccountDataExportController : Controller
|
||||
case "Inventory": await AddInventorySheet(package, companyId, headerColor); break;
|
||||
case "Equipment": await AddEquipmentSheet(package, companyId, headerColor); break;
|
||||
case "Vendors": await AddVendorsSheet(package, companyId, headerColor); break;
|
||||
case "ShopWorkers": await AddShopWorkersSheet(package, companyId, headerColor); break;
|
||||
case "Users": await AddUsersSheet(package, companyId, headerColor); break;
|
||||
}
|
||||
}
|
||||
@@ -182,7 +181,6 @@ public class AccountDataExportController : Controller
|
||||
case "Inventory": WriteCsvEntry(zip, "Inventory.csv", await BuildInventoryCsv(companyId)); break;
|
||||
case "Equipment": WriteCsvEntry(zip, "Equipment.csv", await BuildEquipmentCsv(companyId)); break;
|
||||
case "Vendors": WriteCsvEntry(zip, "Vendors.csv", await BuildVendorsCsv(companyId)); break;
|
||||
case "ShopWorkers": WriteCsvEntry(zip, "ShopWorkers.csv", await BuildShopWorkersCsv(companyId)); break;
|
||||
case "Users": WriteCsvEntry(zip, "Users.csv", await BuildUsersCsv(companyId)); break;
|
||||
}
|
||||
}
|
||||
@@ -268,12 +266,6 @@ public class AccountDataExportController : Controller
|
||||
.Where(s => s.CompanyId == companyId && !s.IsDeleted)
|
||||
.OrderBy(s => s.CompanyName).ToListAsync();
|
||||
|
||||
/// <summary>Fetches all non-deleted shop workers for the company.</summary>
|
||||
private Task<List<ShopWorker>> FetchShopWorkersAsync(int companyId) =>
|
||||
_db.ShopWorkers.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(w => w.CompanyId == companyId && !w.IsDeleted)
|
||||
.OrderBy(w => w.Name).ToListAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Fetches all users for the company. <c>IsDeleted</c> is intentionally omitted because
|
||||
/// Identity users use <c>IsActive = false</c> for soft-deletion, not the base-entity flag.
|
||||
@@ -462,23 +454,6 @@ public class AccountDataExportController : Controller
|
||||
AutoFit(ws, headers.Length);
|
||||
}
|
||||
|
||||
private async Task AddShopWorkersSheet(ExcelPackage pkg, int companyId, Color hdr)
|
||||
{
|
||||
var data = await FetchShopWorkersAsync(companyId);
|
||||
var ws = pkg.Workbook.Worksheets.Add("Shop Workers");
|
||||
var headers = new[] { "ID", "Name", "Role", "Phone", "Email", "Active", "Notes" };
|
||||
WriteHeader(ws, headers, hdr);
|
||||
for (int i = 0; i < data.Count; i++)
|
||||
{
|
||||
var r = i + 2; var w = data[i];
|
||||
ws.Cells[r, 1].Value = w.Id; ws.Cells[r, 2].Value = w.Name;
|
||||
ws.Cells[r, 3].Value = w.Role.ToString(); ws.Cells[r, 4].Value = w.Phone;
|
||||
ws.Cells[r, 5].Value = w.Email; ws.Cells[r, 6].Value = w.IsActive ? "Yes" : "No";
|
||||
ws.Cells[r, 7].Value = w.Notes;
|
||||
}
|
||||
AutoFit(ws, headers.Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a "Users" worksheet. All users (active and inactive) are included because Identity
|
||||
/// uses <c>IsActive = false</c> for soft-deletion; <c>IsDeleted</c> is not applicable here.
|
||||
@@ -611,17 +586,6 @@ public class AccountDataExportController : Controller
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Column names match <c>ShopWorkerImportDto</c> exactly so the file can be re-imported.</summary>
|
||||
private async Task<string> BuildShopWorkersCsv(int companyId)
|
||||
{
|
||||
var data = await FetchShopWorkersAsync(companyId);
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Name,Role,Phone,Email,IsActive,Notes");
|
||||
foreach (var w in data)
|
||||
sb.AppendLine($"{CsvEscape(w.Name)},{w.Role},{CsvEscape(w.Phone)},{CsvEscape(w.Email)},{w.IsActive.ToString().ToLower()},{CsvEscape(w.Notes)}");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// All users (active and inactive) are exported for completeness and compliance — mirrors
|
||||
/// the reasoning in <see cref="AddUsersSheet"/> and <see cref="FetchUsersAsync"/>.
|
||||
@@ -675,13 +639,13 @@ public class AccountDataExportController : Controller
|
||||
|
||||
/// <summary>
|
||||
/// Returns the subset of selected sheet names reordered into the canonical export sequence
|
||||
/// (Customers → Jobs → Quotes → Invoices → Inventory → Equipment → Vendors → ShopWorkers → Users).
|
||||
/// (Customers → Jobs → Quotes → Invoices → Inventory → Equipment → Vendors → Users).
|
||||
/// Guarantees consistent file layout regardless of the order check-boxes were ticked on the form.
|
||||
/// Sheet names not in the canonical list are silently dropped.
|
||||
/// </summary>
|
||||
private static string[] OrderSheets(string[] sheets)
|
||||
{
|
||||
var order = new[] { "Customers", "Jobs", "Quotes", "Invoices", "Inventory", "Equipment", "Vendors", "ShopWorkers", "Users" };
|
||||
var order = new[] { "Customers", "Jobs", "Quotes", "Invoices", "Inventory", "Equipment", "Vendors", "Users" };
|
||||
return order.Where(sheets.Contains).ToArray();
|
||||
}
|
||||
|
||||
|
||||
@@ -756,7 +756,6 @@ public class CompanySettingsController : Controller
|
||||
var costs = company.OperatingCosts;
|
||||
|
||||
var ovens = (await _unitOfWork.OvenCosts.FindAsync(o => o.IsActive)).OrderBy(o => o.DisplayOrder).ToList();
|
||||
var workers = (await _unitOfWork.ShopWorkers.FindAsync(w => w.IsActive)).ToList();
|
||||
var coatingCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsCoating)).ToList();
|
||||
|
||||
var sb = new System.Text.StringBuilder();
|
||||
@@ -783,8 +782,7 @@ public class CompanySettingsController : Controller
|
||||
ShopCapabilityTier.Large => "high-volume",
|
||||
_ => "small"
|
||||
};
|
||||
sb.AppendLine($"We are a {tierLabel} operation" +
|
||||
(workers.Count > 0 ? $" with {workers.Count} active shop worker{(workers.Count == 1 ? "" : "s")}." : "."));
|
||||
sb.AppendLine($"We are a {tierLabel} operation.");
|
||||
}
|
||||
|
||||
// Ovens
|
||||
@@ -827,32 +825,6 @@ public class CompanySettingsController : Controller
|
||||
sb.AppendLine($"Powder categories we stock: {string.Join(", ", catNames)}.");
|
||||
}
|
||||
|
||||
// Worker roles
|
||||
if (workers.Any())
|
||||
{
|
||||
var roles = workers
|
||||
.Select(w => w.Role)
|
||||
.Distinct()
|
||||
.Select(r => r switch
|
||||
{
|
||||
ShopWorkerRole.Sandblaster => "sandblasting",
|
||||
ShopWorkerRole.Coater => "powder coating",
|
||||
ShopWorkerRole.Masker => "masking",
|
||||
ShopWorkerRole.QualityControl => "quality control",
|
||||
ShopWorkerRole.OvenOperator => "oven operation",
|
||||
ShopWorkerRole.Supervisor => "supervision",
|
||||
ShopWorkerRole.Maintenance => "equipment maintenance",
|
||||
_ => "general labor"
|
||||
})
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (roles.Count > 1)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Staff specialties on hand: {string.Join(", ", roles)}.");
|
||||
}
|
||||
}
|
||||
|
||||
// Rates hint
|
||||
if (costs != null && costs.StandardLaborRate > 0)
|
||||
{
|
||||
@@ -2719,79 +2691,6 @@ public class CompanySettingsController : Controller
|
||||
|
||||
// ── Role-Based Labor Rates ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Returns the per-role hourly labor rates configured for the current company, keyed by
|
||||
/// <see cref="PowderCoating.Core.Enums.ShopWorkerRole"/> integer value. An empty list is returned
|
||||
/// (rather than a 404) when no rates have been configured yet, so the UI can render the rate grid
|
||||
/// without special-casing an empty state. The global multi-tenant filter on
|
||||
/// <c>ShopWorkerRoleCosts</c> ensures only this company's rates are returned.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetRoleCosts()
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (companyId == null) return Json(new List<object>());
|
||||
|
||||
var rates = await _unitOfWork.ShopWorkerRoleCosts.FindAsync(r => r.CompanyId == companyId.Value);
|
||||
var result = rates.Select(r => new { role = (int)r.Role, hourlyRate = r.HourlyRate }).ToList();
|
||||
return Json(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upserts the per-role hourly labor rates for the current company. The operation handles three cases
|
||||
/// per rate in a single pass: (1) rate cleared (≤ 0) — soft-delete the existing record; (2) rate set
|
||||
/// but no existing record — insert new; (3) rate changed — update existing. This avoids full
|
||||
/// table replace semantics that could cause audit log noise or trigger unintended EF change-tracking.
|
||||
/// These rates are used by the pricing calculator when <c>UseRoleBasedLaborRates</c> is enabled in
|
||||
/// <c>CompanyOperatingCosts</c>.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> SaveRoleCosts([FromBody] List<SaveRoleCostDto> rates)
|
||||
{
|
||||
try
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (companyId == null) return Json(new { success = false, message = "No company found." });
|
||||
|
||||
var existing = (await _unitOfWork.ShopWorkerRoleCosts.FindAsync(r => r.CompanyId == companyId.Value)).ToList();
|
||||
|
||||
foreach (var dto in rates)
|
||||
{
|
||||
var record = existing.FirstOrDefault(r => (int)r.Role == dto.Role);
|
||||
if (dto.HourlyRate <= 0)
|
||||
{
|
||||
// Remove rate if cleared
|
||||
if (record != null)
|
||||
await _unitOfWork.ShopWorkerRoleCosts.SoftDeleteAsync(record.Id);
|
||||
}
|
||||
else if (record == null)
|
||||
{
|
||||
await _unitOfWork.ShopWorkerRoleCosts.AddAsync(new PowderCoating.Core.Entities.ShopWorkerRoleCost
|
||||
{
|
||||
CompanyId = companyId.Value,
|
||||
Role = (PowderCoating.Core.Enums.ShopWorkerRole)dto.Role,
|
||||
HourlyRate = dto.HourlyRate,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
record.HourlyRate = dto.HourlyRate;
|
||||
record.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.ShopWorkerRoleCosts.UpdateAsync(record);
|
||||
}
|
||||
}
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return Json(new { success = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error saving role costs");
|
||||
return Json(new { success = false, message = "An error occurred saving role rates." });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Stripe Connect ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
@@ -3055,7 +2954,6 @@ public class CompanySettingsController : Controller
|
||||
}
|
||||
|
||||
public record SaveTemplateJsonRequest(int Id, string? Subject, string? Body);
|
||||
public record SaveRoleCostDto(int Role, decimal HourlyRate);
|
||||
public record SaveOnlinePaymentSettingsDto(
|
||||
OnlinePaymentSurchargeType SurchargeType,
|
||||
decimal SurchargeValue,
|
||||
|
||||
@@ -226,11 +226,9 @@ public class CompanyUsersController : Controller
|
||||
/// Creates a new company user, enforcing the subscription user-count limit and a whitelist
|
||||
/// of valid <c>CompanyRole</c> values (preventing callers from submitting a null role to
|
||||
/// create a SuperAdmin-equivalent account). CompanyAdmin users automatically receive all
|
||||
/// per-feature permissions unless a SuperAdmin is explicitly customising them. Workers
|
||||
/// additionally get an auto-created <see cref="ShopWorker"/> record so they appear in job
|
||||
/// assignment dropdowns without a separate onboarding step. A legacy ASP.NET Identity role
|
||||
/// (Administrator / Manager / Employee / ReadOnly) is also assigned to satisfy policy
|
||||
/// checks that still reference the role system.
|
||||
/// per-feature permissions unless a SuperAdmin is explicitly customising them. A legacy
|
||||
/// ASP.NET Identity role (Administrator / Manager / Employee / ReadOnly) is also assigned
|
||||
/// to satisfy policy checks that still reference the role system.
|
||||
/// </summary>
|
||||
// POST: CompanyUsers/Create
|
||||
[HttpPost]
|
||||
@@ -351,27 +349,7 @@ public class CompanyUsersController : Controller
|
||||
|
||||
await _userManager.AddToRoleAsync(user, legacyRole);
|
||||
|
||||
// If Worker role, automatically create a ShopWorker record
|
||||
if (model.CompanyRole == AppConstants.CompanyRoles.Worker)
|
||||
{
|
||||
var shopWorker = new ShopWorker
|
||||
{
|
||||
Name = user.FullName,
|
||||
Email = user.Email,
|
||||
Phone = user.PhoneNumber,
|
||||
IsActive = true,
|
||||
Notes = $"Auto-created from user account: {user.Email}",
|
||||
Role = Core.Enums.ShopWorkerRole.GeneralLabor, // Default role
|
||||
CompanyId = companyId!.Value
|
||||
};
|
||||
|
||||
await _unitOfWork.ShopWorkers.AddAsync(shopWorker);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogInformation("ShopWorker record created for user {Email}", user.Email);
|
||||
}
|
||||
|
||||
_logger.LogInformation("User {Email} created successfully by {Admin}",
|
||||
_logger.LogInformation("User {Email} created successfully by {Admin}",
|
||||
user.Email, User.Identity?.Name);
|
||||
|
||||
TempData["Success"] = $"User '{user.FullName}' created successfully.";
|
||||
@@ -441,6 +419,7 @@ public class CompanyUsersController : Controller
|
||||
CompanyRole = user.CompanyRole ?? AppConstants.CompanyRoles.Viewer,
|
||||
Department = user.Department,
|
||||
Position = user.Position,
|
||||
LaborCostPerHour = user.LaborCostPerHour,
|
||||
Phone = user.PhoneNumber,
|
||||
IsActive = user.IsActive,
|
||||
HireDate = user.HireDate,
|
||||
@@ -479,11 +458,9 @@ public class CompanyUsersController : Controller
|
||||
/// Saves changes to an existing company user. Validates company isolation and role whitelist
|
||||
/// (same checks as <see cref="Edit(string, string)"/>). Prevents two dangerous deactivation
|
||||
/// scenarios: a user deactivating themselves, and deactivating the last active CompanyAdmin
|
||||
/// for a company (which would lock out the tenant). When the role changes to Worker and no
|
||||
/// matching <see cref="ShopWorker"/> record exists, one is created automatically; if one
|
||||
/// already exists, its name, email, and active status are kept in sync. Email changes are
|
||||
/// applied via <c>SetEmailAsync</c> / <c>SetUserNameAsync</c> after the main update so
|
||||
/// Identity's own normalisation logic runs correctly.
|
||||
/// for a company (which would lock out the tenant). Email changes are applied via
|
||||
/// <c>SetEmailAsync</c> / <c>SetUserNameAsync</c> after the main update so Identity's own
|
||||
/// normalisation logic runs correctly.
|
||||
/// </summary>
|
||||
// POST: CompanyUsers/Edit/id
|
||||
[HttpPost]
|
||||
@@ -596,6 +573,7 @@ public class CompanyUsersController : Controller
|
||||
user.CompanyRole = model.CompanyRole;
|
||||
user.Department = model.Department;
|
||||
user.Position = model.Position;
|
||||
user.LaborCostPerHour = model.LaborCostPerHour;
|
||||
user.PhoneNumber = model.Phone;
|
||||
user.IsActive = model.IsActive;
|
||||
user.HireDate = model.HireDate;
|
||||
@@ -632,60 +610,7 @@ public class CompanyUsersController : Controller
|
||||
user.Id, oldEmail, model.Email, User.Identity?.Name);
|
||||
}
|
||||
|
||||
// If role changed to Worker, ensure ShopWorker record exists
|
||||
if (model.CompanyRole == AppConstants.CompanyRoles.Worker)
|
||||
{
|
||||
// Search by oldEmail so we find the record even when the email just changed
|
||||
var lookupEmail = emailChanged ? oldEmail : user.Email;
|
||||
var existingShopWorker = (await _unitOfWork.ShopWorkers.FindAsync(
|
||||
sw => sw.Email == lookupEmail && sw.CompanyId == user.CompanyId)).ToList();
|
||||
|
||||
if (!existingShopWorker.Any())
|
||||
{
|
||||
var shopWorker = new ShopWorker
|
||||
{
|
||||
Name = user.FullName,
|
||||
Email = user.Email,
|
||||
Phone = user.PhoneNumber,
|
||||
IsActive = user.IsActive,
|
||||
Notes = $"Auto-created from user account: {user.Email}",
|
||||
Role = Core.Enums.ShopWorkerRole.GeneralLabor, // Default role
|
||||
CompanyId = user.CompanyId
|
||||
};
|
||||
|
||||
await _unitOfWork.ShopWorkers.AddAsync(shopWorker);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogInformation("ShopWorker record created for user {Email}", user.Email);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Update existing ShopWorker to ensure it's active
|
||||
var shopWorker = existingShopWorker.First();
|
||||
var shopWorkerDirty = false;
|
||||
|
||||
if (!shopWorker.IsActive && user.IsActive)
|
||||
{
|
||||
shopWorker.IsActive = true;
|
||||
shopWorkerDirty = true;
|
||||
_logger.LogInformation("ShopWorker record reactivated for user {Email}", user.Email);
|
||||
}
|
||||
|
||||
if (emailChanged && shopWorker.Email == oldEmail)
|
||||
{
|
||||
shopWorker.Email = user.Email;
|
||||
shopWorkerDirty = true;
|
||||
}
|
||||
|
||||
shopWorker.Name = user.FullName;
|
||||
shopWorker.Phone = user.PhoneNumber;
|
||||
|
||||
if (shopWorkerDirty)
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("User {Email} updated successfully by {Admin}",
|
||||
_logger.LogInformation("User {Email} updated successfully by {Admin}",
|
||||
user.Email, User.Identity?.Name);
|
||||
|
||||
TempData["Success"] = "User updated successfully.";
|
||||
|
||||
@@ -122,7 +122,6 @@ public class DataExportController : Controller
|
||||
case "Inventory": await AddInventorySheet(package, companyId, headerColor); break;
|
||||
case "Equipment": await AddEquipmentSheet(package, companyId, headerColor); break;
|
||||
case "Vendors": await AddVendorsSheet(package, companyId, headerColor); break;
|
||||
case "ShopWorkers": await AddShopWorkersSheet(package, companyId, headerColor); break;
|
||||
case "Users": await AddUsersSheet(package, companyId, headerColor); break;
|
||||
}
|
||||
}
|
||||
@@ -172,7 +171,6 @@ public class DataExportController : Controller
|
||||
case "Inventory": WriteCsvEntry(zip, "Inventory.csv", await BuildInventoryCsv(companyId)); break;
|
||||
case "Equipment": WriteCsvEntry(zip, "Equipment.csv", await BuildEquipmentCsv(companyId)); break;
|
||||
case "Vendors": WriteCsvEntry(zip, "Vendors.csv", await BuildVendorsCsv(companyId)); break;
|
||||
case "ShopWorkers": WriteCsvEntry(zip, "ShopWorkers.csv", await BuildShopWorkersCsv(companyId)); break;
|
||||
case "Users": WriteCsvEntry(zip, "Users.csv", await BuildUsersCsv(companyId)); break;
|
||||
}
|
||||
}
|
||||
@@ -441,38 +439,6 @@ public class DataExportController : Controller
|
||||
AutoFit(ws, headers.Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a "Shop Workers" worksheet with one row per non-deleted shop worker for the
|
||||
/// specified company. <c>Role.ToString()</c> converts the enum to a string; the view
|
||||
/// typically formats these with spaces (e.g. "QualityControl" → "Quality Control") but the
|
||||
/// raw enum name is used here so the export value is round-trip parseable.
|
||||
/// </summary>
|
||||
private async Task AddShopWorkersSheet(ExcelPackage pkg, int companyId, Color hdr)
|
||||
{
|
||||
var data = await _db.ShopWorkers.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(w => w.CompanyId == companyId && !w.IsDeleted)
|
||||
.OrderBy(w => w.Name)
|
||||
.ToListAsync();
|
||||
|
||||
var ws = pkg.Workbook.Worksheets.Add("Shop Workers");
|
||||
var headers = new[] { "ID", "Name", "Role", "Phone", "Email", "Active", "Notes" };
|
||||
WriteHeader(ws, headers, hdr);
|
||||
|
||||
for (int i = 0; i < data.Count; i++)
|
||||
{
|
||||
var r = i + 2;
|
||||
var w = data[i];
|
||||
ws.Cells[r, 1].Value = w.Id;
|
||||
ws.Cells[r, 2].Value = w.Name;
|
||||
ws.Cells[r, 3].Value = w.Role.ToString();
|
||||
ws.Cells[r, 4].Value = w.Phone;
|
||||
ws.Cells[r, 5].Value = w.Email;
|
||||
ws.Cells[r, 6].Value = w.IsActive ? "Yes" : "No";
|
||||
ws.Cells[r, 7].Value = w.Notes;
|
||||
}
|
||||
AutoFit(ws, headers.Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an "Invoices" worksheet with one row per non-deleted invoice for the specified company.
|
||||
/// The customer navigation is eagerly loaded so the customer name can be rendered; when a
|
||||
@@ -687,21 +653,6 @@ public class DataExportController : Controller
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the shop workers CSV string for the specified company, ordered alphabetically by name.
|
||||
/// Column names match <see cref="ShopWorkerImportDto"/> exactly so the file can be re-imported.
|
||||
/// </summary>
|
||||
private async Task<string> BuildShopWorkersCsv(int companyId)
|
||||
{
|
||||
var data = await _db.ShopWorkers.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(w => w.CompanyId == companyId && !w.IsDeleted).OrderBy(w => w.Name).ToListAsync();
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Name,Role,Phone,Email,IsActive,Notes");
|
||||
foreach (var w in data)
|
||||
sb.AppendLine($"{CsvEscape(w.Name)},{w.Role},{CsvEscape(w.Phone)},{CsvEscape(w.Email)},{w.IsActive.ToString().ToLower()},{CsvEscape(w.Notes)}");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the users CSV string for the specified company, ordered by last name.
|
||||
/// Like <see cref="AddUsersSheet"/>, the <c>IsDeleted</c> filter is intentionally omitted
|
||||
@@ -769,7 +720,7 @@ public class DataExportController : Controller
|
||||
/// <param name="sheets">Raw sheet names from the form POST.</param>
|
||||
private static string[] OrderSheets(string[] sheets)
|
||||
{
|
||||
var order = new[] { "Customers", "Jobs", "Quotes", "Invoices", "Inventory", "Equipment", "Vendors", "ShopWorkers", "Users" };
|
||||
var order = new[] { "Customers", "Jobs", "Quotes", "Invoices", "Inventory", "Equipment", "Vendors", "Users" };
|
||||
return order.Where(sheets.Contains).ToArray();
|
||||
}
|
||||
|
||||
|
||||
@@ -175,7 +175,6 @@ public class DataPurgeController : Controller
|
||||
stats.Add(await Stat("Equipment", "Equipment", "bi-tools", "Inventory & Ops", _db.Equipment.Where(e => e.IsDeleted)));
|
||||
stats.Add(await Stat("MaintenanceRecords","Maintenance Records", "bi-wrench", "Inventory & Ops", _db.MaintenanceRecords.Where(e => e.IsDeleted)));
|
||||
stats.Add(await Stat("Vendors", "Vendors", "bi-truck", "Inventory & Ops", _db.Vendors.Where(e => e.IsDeleted)));
|
||||
stats.Add(await Stat("ShopWorkers", "Shop Workers", "bi-person-badge","Inventory & Ops", _db.ShopWorkers.Where(e => e.IsDeleted)));
|
||||
|
||||
return stats;
|
||||
}
|
||||
@@ -204,7 +203,6 @@ public class DataPurgeController : Controller
|
||||
"Equipment" => await QueryCount(_db.Equipment, cutoff),
|
||||
"MaintenanceRecords" => await QueryCount(_db.MaintenanceRecords, cutoff),
|
||||
"Vendors" => await QueryCount(_db.Vendors, cutoff),
|
||||
"ShopWorkers" => await QueryCount(_db.ShopWorkers, cutoff),
|
||||
_ => (0, null)
|
||||
};
|
||||
}
|
||||
@@ -324,11 +322,6 @@ public class DataPurgeController : Controller
|
||||
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
|
||||
break;
|
||||
|
||||
case "ShopWorkers":
|
||||
count = await _db.ShopWorkers.IgnoreQueryFilters()
|
||||
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
|
||||
break;
|
||||
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -38,14 +38,6 @@ namespace PowderCoating.Web.Controllers
|
||||
return View();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serves the Shop Workers help article describing roles, assignment to jobs, and maintenance tasks.
|
||||
/// </summary>
|
||||
public IActionResult ShopWorkers()
|
||||
{
|
||||
return View();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serves the Equipment help article explaining the equipment status lifecycle and maintenance scheduling.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using System.Text.Json;
|
||||
using AutoMapper;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PowderCoating.Application.DTOs.Common;
|
||||
using PowderCoating.Application.DTOs.Invoice;
|
||||
using PowderCoating.Application.DTOs.Quote;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
@@ -397,11 +399,13 @@ public class InvoicesController : Controller
|
||||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||
{
|
||||
SourceJobItemId = item.Id,
|
||||
CatalogItemId = item.CatalogItemId,
|
||||
Description = item.Description ?? "Powder Coating",
|
||||
Quantity = item.Quantity > 0 ? item.Quantity : 1,
|
||||
UnitPrice = item.UnitPrice,
|
||||
TotalPrice = item.TotalPrice,
|
||||
ColorName = item.ColorName,
|
||||
Notes = item.Notes,
|
||||
DisplayOrder = order++,
|
||||
RevenueAccountId = revenueAccountId
|
||||
});
|
||||
@@ -437,7 +441,10 @@ public class InvoicesController : Controller
|
||||
// because FinalPrice is recalculated on every item edit and can drift from the original quote.
|
||||
if (sourceQuote != null)
|
||||
{
|
||||
// Bundle all quote-level charges so the invoice subtotal matches the quote total.
|
||||
// FacilityOverheadCost is included — it is a real cost baked into the quoted price.
|
||||
var processingFees = sourceQuote.OvenBatchCost
|
||||
+ sourceQuote.FacilityOverheadCost
|
||||
+ sourceQuote.ShopSuppliesAmount
|
||||
+ sourceQuote.RushFee;
|
||||
|
||||
@@ -460,15 +467,17 @@ public class InvoicesController : Controller
|
||||
}
|
||||
else if (hadJobItems)
|
||||
{
|
||||
// Direct job — no source quote. Use the stored job-level fees rather than
|
||||
// recalculating, so the invoice always matches the total shown on the job page.
|
||||
// OvenBatchCost and ShopSuppliesAmount are saved by the pricing engine (with
|
||||
// OvenCostId) when job items are created or updated.
|
||||
// Direct job — no source quote. Read all charges from the pricing snapshot so the
|
||||
// invoice always matches the total shown on the job's Pricing Summary card.
|
||||
QuotePricingBreakdownDto? jobBreakdown = null;
|
||||
if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
|
||||
jobBreakdown = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson);
|
||||
|
||||
if (job.OvenBatchCost > 0.01m)
|
||||
{
|
||||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||
{
|
||||
Description = $"Oven Processing Fee",
|
||||
Description = "Oven Processing Fee",
|
||||
Quantity = 1,
|
||||
UnitPrice = Math.Round(job.OvenBatchCost, 2),
|
||||
TotalPrice = Math.Round(job.OvenBatchCost, 2),
|
||||
@@ -477,6 +486,20 @@ public class InvoicesController : Controller
|
||||
});
|
||||
}
|
||||
|
||||
var facilityOverhead = jobBreakdown?.FacilityOverheadCost ?? 0m;
|
||||
if (facilityOverhead > 0.01m)
|
||||
{
|
||||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||
{
|
||||
Description = "Facility Overhead",
|
||||
Quantity = 1,
|
||||
UnitPrice = Math.Round(facilityOverhead, 2),
|
||||
TotalPrice = Math.Round(facilityOverhead, 2),
|
||||
DisplayOrder = order++,
|
||||
RevenueAccountId = defaultRevenueAccount?.Id
|
||||
});
|
||||
}
|
||||
|
||||
if (job.ShopSuppliesAmount > 0.01m)
|
||||
{
|
||||
var suppliesDesc = job.ShopSuppliesPercent > 0
|
||||
@@ -488,6 +511,20 @@ public class InvoicesController : Controller
|
||||
Quantity = 1,
|
||||
UnitPrice = Math.Round(job.ShopSuppliesAmount, 2),
|
||||
TotalPrice = Math.Round(job.ShopSuppliesAmount, 2),
|
||||
DisplayOrder = order++,
|
||||
RevenueAccountId = defaultRevenueAccount?.Id
|
||||
});
|
||||
}
|
||||
|
||||
var rushFee = jobBreakdown?.RushFee ?? 0m;
|
||||
if (rushFee > 0.01m)
|
||||
{
|
||||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||
{
|
||||
Description = "Rush Fee",
|
||||
Quantity = 1,
|
||||
UnitPrice = Math.Round(rushFee, 2),
|
||||
TotalPrice = Math.Round(rushFee, 2),
|
||||
DisplayOrder = order,
|
||||
RevenueAccountId = defaultRevenueAccount?.Id
|
||||
});
|
||||
|
||||
@@ -422,72 +422,24 @@ public class JobsController : Controller
|
||||
// Populate Edit Items wizard data (inline modal on Details page)
|
||||
var wizardCosts = await _pricingService.GetOperatingCostsAsync(job.CompanyId);
|
||||
await PopulateJobItemDropDownsAsync(job.CompanyId, wizardCosts?.OvenOperatingCostPerHour ?? 45m);
|
||||
ViewBag.WizardTaxPercent = wizardCosts?.TaxPercent ?? 0m;
|
||||
ViewBag.WizardTaxPercent = await GetEffectiveTaxPercentAsync(job.CustomerId, wizardCosts?.TaxPercent ?? 0m);
|
||||
|
||||
// Internal pricing breakdown (not printed — mirrors quote details breakdown)
|
||||
var breakdownItems = job.JobItems
|
||||
.Where(ji => !ji.IsDeleted)
|
||||
.Select(ji => new CreateQuoteItemDto
|
||||
{
|
||||
Description = ji.Description,
|
||||
Quantity = ji.Quantity,
|
||||
SurfaceAreaSqFt = ji.SurfaceAreaSqFt,
|
||||
EstimatedMinutes = ji.EstimatedMinutes,
|
||||
CatalogItemId = ji.CatalogItemId,
|
||||
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && !ji.Coats.Any() && !ji.IsSalesItem),
|
||||
IsLaborItem = ji.IsLaborItem,
|
||||
IsSalesItem = ji.IsSalesItem,
|
||||
ManualUnitPrice = ji.ManualUnitPrice ?? ((ji.IsGenericItem || ji.IsSalesItem) ? ji.UnitPrice : (decimal?)null),
|
||||
PowderCostOverride = ji.PowderCostOverride,
|
||||
IncludePrepCost = ji.IncludePrepCost,
|
||||
Complexity = ji.Complexity,
|
||||
Coats = ji.Coats.OrderBy(c => c.Sequence).Select(c => new CreateQuoteItemCoatDto
|
||||
{
|
||||
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
||||
TransferEfficiency = c.TransferEfficiency,
|
||||
PowderCostPerLb = c.PowderCostPerLb,
|
||||
PowderToOrder = c.PowderToOrder
|
||||
}).ToList(),
|
||||
PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto
|
||||
{
|
||||
PrepServiceId = ps.PrepServiceId,
|
||||
EstimatedMinutes = ps.EstimatedMinutes
|
||||
}).ToList()
|
||||
}).ToList();
|
||||
|
||||
if (breakdownItems.Any())
|
||||
// Display the pricing snapshot stored when items were last saved.
|
||||
// Never recalculate on load — operating cost changes must not retroactively alter existing jobs.
|
||||
if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
|
||||
{
|
||||
var pr = await _pricingService.CalculateQuoteTotalsAsync(
|
||||
breakdownItems, job.CompanyId, job.CustomerId,
|
||||
wizardCosts?.TaxPercent ?? 0m,
|
||||
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
|
||||
job.OvenCostId, job.OvenBatches, job.OvenCycleMinutes);
|
||||
|
||||
ViewBag.JobPricingBreakdown = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson);
|
||||
}
|
||||
else if (job.FinalPrice > 0)
|
||||
{
|
||||
// Legacy job created before snapshot was introduced — show what we have stored
|
||||
ViewBag.JobPricingBreakdown = new QuotePricingBreakdownDto
|
||||
{
|
||||
MaterialCosts = pr.MaterialCosts,
|
||||
LaborCosts = pr.LaborCosts,
|
||||
EquipmentCosts = pr.EquipmentCosts,
|
||||
ItemsSubtotal = pr.ItemsSubtotal,
|
||||
OvenBatchCost = pr.OvenBatchCost,
|
||||
OvenBatches = pr.OvenBatches,
|
||||
OvenCycleMinutes = pr.OvenCycleMinutes > 0 ? pr.OvenCycleMinutes : (wizardCosts?.DefaultOvenCycleMinutes ?? 0),
|
||||
FacilityOverheadCost = pr.FacilityOverheadCost,
|
||||
FacilityOverheadRatePerHour = pr.FacilityOverheadRatePerHour,
|
||||
ShopSuppliesAmount = pr.ShopSuppliesAmount,
|
||||
ShopSuppliesPercent = pr.ShopSuppliesPercent,
|
||||
OverheadCosts = pr.OverheadCosts,
|
||||
OverheadPercent = pr.OverheadPercent,
|
||||
ProfitMargin = pr.ProfitMargin,
|
||||
ProfitPercent = pr.ProfitPercent,
|
||||
SubtotalBeforeDiscount = pr.SubtotalBeforeDiscount,
|
||||
DiscountAmount = pr.DiscountAmount,
|
||||
DiscountPercent = pr.DiscountPercent,
|
||||
SubtotalAfterDiscount = pr.SubtotalAfterDiscount,
|
||||
RushFee = pr.RushFee,
|
||||
TaxAmount = pr.TaxAmount,
|
||||
TaxPercent = pr.TaxPercent,
|
||||
Total = pr.Total
|
||||
OvenBatchCost = job.OvenBatchCost,
|
||||
OvenBatches = job.OvenBatches,
|
||||
ShopSuppliesAmount = job.ShopSuppliesAmount,
|
||||
ShopSuppliesPercent = job.ShopSuppliesPercent,
|
||||
Total = job.FinalPrice
|
||||
};
|
||||
}
|
||||
ViewBag.ComplexitySimplePercent = wizardCosts?.ComplexitySimplePercent ?? 0m;
|
||||
@@ -1169,15 +1121,23 @@ public class JobsController : Controller
|
||||
|
||||
// Recalculate total from wizard items
|
||||
var createCosts = await _pricingService.GetOperatingCostsAsync(companyId);
|
||||
decimal? createOvenRate = null;
|
||||
if (dto.OvenCostId.HasValue)
|
||||
{
|
||||
var createOven = await _unitOfWork.OvenCosts.GetByIdAsync(dto.OvenCostId.Value);
|
||||
if (createOven != null && createOven.CompanyId == companyId)
|
||||
createOvenRate = createOven.CostPerHour;
|
||||
}
|
||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||
dto.JobItems, companyId, dto.CustomerId,
|
||||
createCosts?.TaxPercent ?? 0m,
|
||||
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, job.OvenCostId, job.OvenBatches, job.OvenCycleMinutes);
|
||||
await GetEffectiveTaxPercentAsync(dto.CustomerId, createCosts?.TaxPercent ?? 0m),
|
||||
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, createOvenRate, job.OvenBatches, job.OvenCycleMinutes);
|
||||
|
||||
job.FinalPrice = totals.Total;
|
||||
job.OvenBatchCost = totals.OvenBatchCost;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
|
||||
job.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.Jobs.UpdateAsync(job);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
@@ -1629,14 +1589,22 @@ public class JobsController : Controller
|
||||
if (dto.JobItems.Any())
|
||||
{
|
||||
var editCosts = await _pricingService.GetOperatingCostsAsync(companyId);
|
||||
decimal? editOvenRate = null;
|
||||
if (job.OvenCostId.HasValue)
|
||||
{
|
||||
var editOven = await _unitOfWork.OvenCosts.GetByIdAsync(job.OvenCostId.Value);
|
||||
if (editOven != null && editOven.CompanyId == companyId)
|
||||
editOvenRate = editOven.CostPerHour;
|
||||
}
|
||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||
dto.JobItems, companyId, dto.CustomerId,
|
||||
editCosts?.TaxPercent ?? 0m,
|
||||
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, job.OvenCostId, job.OvenBatches, job.OvenCycleMinutes);
|
||||
job.FinalPrice = totals.Total;
|
||||
job.OvenBatchCost = totals.OvenBatchCost;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
await GetEffectiveTaxPercentAsync(dto.CustomerId, editCosts?.TaxPercent ?? 0m),
|
||||
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, editOvenRate, job.OvenBatches, job.OvenCycleMinutes);
|
||||
job.FinalPrice = totals.Total;
|
||||
job.OvenBatchCost = totals.OvenBatchCost;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
|
||||
}
|
||||
|
||||
// Save change history records
|
||||
@@ -2962,7 +2930,7 @@ public class JobsController : Controller
|
||||
JobId = job.Id,
|
||||
JobNumber = job.JobNumber,
|
||||
CustomerId = job.CustomerId,
|
||||
TaxPercent = costs?.TaxPercent ?? 0m,
|
||||
TaxPercent = await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m),
|
||||
OvenCostId = job.OvenCostId,
|
||||
OvenBatches = job.OvenBatches > 0 ? job.OvenBatches : 1,
|
||||
OvenCycleMinutes = job.OvenCycleMinutes,
|
||||
@@ -2999,7 +2967,7 @@ public class JobsController : Controller
|
||||
{
|
||||
ModelState.AddModelError("", "Please add at least one job item.");
|
||||
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
|
||||
model.TaxPercent = costs?.TaxPercent ?? 0m;
|
||||
model.TaxPercent = await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m);
|
||||
await PopulateJobItemDropDownsAsync(currentUser.CompanyId, costs?.OvenOperatingCostPerHour ?? 45m);
|
||||
ViewBag.ComplexitySimplePercent = costs?.ComplexitySimplePercent ?? 0m;
|
||||
ViewBag.ComplexityModeratePercent = costs?.ComplexityModeratePercent ?? 5m;
|
||||
@@ -3044,15 +3012,26 @@ public class JobsController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate full total (overhead, margins, tax) to match what the wizard displays
|
||||
// Calculate full total (overhead, margins, tax) matching what Details shows
|
||||
decimal? ovenRateOverride = null;
|
||||
if (job.OvenCostId.HasValue)
|
||||
{
|
||||
var oven = await _unitOfWork.OvenCosts.GetByIdAsync(job.OvenCostId.Value);
|
||||
if (oven != null && oven.CompanyId == currentUser.CompanyId)
|
||||
ovenRateOverride = oven.CostPerHour;
|
||||
}
|
||||
var updateCosts = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
|
||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||
model.JobItems, currentUser.CompanyId, job.CustomerId,
|
||||
model.TaxPercent, "None", 0, false, job.OvenCostId, job.OvenBatches, job.OvenCycleMinutes);
|
||||
await GetEffectiveTaxPercentAsync(job.CustomerId, updateCosts?.TaxPercent ?? 0m),
|
||||
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
|
||||
ovenRateOverride, job.OvenBatches, job.OvenCycleMinutes);
|
||||
|
||||
job.FinalPrice = totals.Total;
|
||||
job.OvenBatchCost = totals.OvenBatchCost;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
job.FinalPrice = totals.Total;
|
||||
job.OvenBatchCost = totals.OvenBatchCost;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
|
||||
job.UpdatedAt = DateTime.UtcNow;
|
||||
job.UpdatedBy = currentUser.UserName;
|
||||
await _unitOfWork.Jobs.UpdateAsync(job);
|
||||
@@ -3066,7 +3045,7 @@ public class JobsController : Controller
|
||||
_logger.LogError(ex, "Error updating items for job {JobId}", job.Id);
|
||||
TempData["Error"] = "An error occurred while saving job items.";
|
||||
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
|
||||
model.TaxPercent = costs?.TaxPercent ?? 0m;
|
||||
model.TaxPercent = await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m);
|
||||
await PopulateJobItemDropDownsAsync(currentUser.CompanyId, costs?.OvenOperatingCostPerHour ?? 45m);
|
||||
return View("EditItems", model);
|
||||
}
|
||||
@@ -3108,31 +3087,47 @@ public class JobsController : Controller
|
||||
CatalogItemId = ji.CatalogItemId,
|
||||
IsGenericItem = ji.IsGenericItem,
|
||||
IsLaborItem = ji.IsLaborItem,
|
||||
IsSalesItem = ji.IsSalesItem,
|
||||
IsAiItem = ji.IsAiItem,
|
||||
ManualUnitPrice = ji.ManualUnitPrice,
|
||||
Coats = ji.Coats.Select(c => new CreateQuoteItemCoatDto
|
||||
ManualUnitPrice = ji.ManualUnitPrice ?? ((ji.IsGenericItem || ji.IsSalesItem) ? ji.UnitPrice : (decimal?)null),
|
||||
IncludePrepCost = ji.IncludePrepCost,
|
||||
Coats = ji.Coats.OrderBy(c => c.Sequence).Select(c => new CreateQuoteItemCoatDto
|
||||
{
|
||||
InventoryItemId = c.InventoryItemId,
|
||||
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
||||
TransferEfficiency = c.TransferEfficiency,
|
||||
PowderCostPerLb = c.PowderCostPerLb
|
||||
}).ToList()
|
||||
}).ToList();
|
||||
|
||||
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
|
||||
if (remainingDtos.Any())
|
||||
{
|
||||
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
|
||||
decimal? deleteOvenRate = null;
|
||||
if (job.OvenCostId.HasValue)
|
||||
{
|
||||
var deleteOven = await _unitOfWork.OvenCosts.GetByIdAsync(job.OvenCostId.Value);
|
||||
if (deleteOven != null && deleteOven.CompanyId == currentUser.CompanyId)
|
||||
deleteOvenRate = deleteOven.CostPerHour;
|
||||
}
|
||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||
remainingDtos, currentUser.CompanyId, job.CustomerId,
|
||||
costs?.TaxPercent ?? 0m, "None", 0, false, null, 1, null);
|
||||
job.FinalPrice = totals.Total;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m),
|
||||
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
|
||||
deleteOvenRate, job.OvenBatches, job.OvenCycleMinutes);
|
||||
job.FinalPrice = totals.Total;
|
||||
job.OvenBatchCost = totals.OvenBatchCost;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
|
||||
}
|
||||
else
|
||||
{
|
||||
job.FinalPrice = 0;
|
||||
job.ShopSuppliesAmount = 0;
|
||||
job.ShopSuppliesPercent = 0;
|
||||
job.FinalPrice = 0;
|
||||
job.OvenBatchCost = 0;
|
||||
job.ShopSuppliesAmount = 0;
|
||||
job.ShopSuppliesPercent = 0;
|
||||
job.PricingBreakdownJson = null;
|
||||
}
|
||||
|
||||
job.UpdatedAt = DateTime.UtcNow;
|
||||
@@ -3242,6 +3237,57 @@ public class JobsController : Controller
|
||||
return $"{string.Join(" > ", path)} > {item.Name}{sku} - {item.DefaultPrice:C}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a <see cref="QuotePricingResult"/> into the DTO used for both display and JSON snapshot storage.
|
||||
/// All save paths (Create, Edit, UpdateItems, DeleteJobItem) call this so the snapshot is always consistent.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Returns the effective tax rate for a job, respecting customer tax-exempt status.
|
||||
/// Always call this instead of using costs.TaxPercent directly so tax-exempt customers
|
||||
/// are never charged tax when a job is saved or recalculated.
|
||||
/// </summary>
|
||||
private async Task<decimal> GetEffectiveTaxPercentAsync(int? customerId, decimal companyDefaultRate)
|
||||
{
|
||||
if (customerId is > 0)
|
||||
{
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(customerId.Value);
|
||||
if (customer?.IsTaxExempt == true) return 0m;
|
||||
}
|
||||
return companyDefaultRate;
|
||||
}
|
||||
|
||||
private static QuotePricingBreakdownDto BuildPricingSnapshotDto(QuotePricingResult pr) =>
|
||||
new QuotePricingBreakdownDto
|
||||
{
|
||||
MaterialCosts = pr.MaterialCosts,
|
||||
LaborCosts = pr.LaborCosts,
|
||||
EquipmentCosts = pr.EquipmentCosts,
|
||||
ItemsSubtotal = pr.ItemsSubtotal,
|
||||
OvenBatchCost = pr.OvenBatchCost,
|
||||
OvenBatches = pr.OvenBatches,
|
||||
OvenCycleMinutes = pr.OvenCycleMinutes,
|
||||
FacilityOverheadCost = pr.FacilityOverheadCost,
|
||||
FacilityOverheadRatePerHour = pr.FacilityOverheadRatePerHour,
|
||||
ShopSuppliesAmount = pr.ShopSuppliesAmount,
|
||||
ShopSuppliesPercent = pr.ShopSuppliesPercent,
|
||||
OverheadCosts = pr.OverheadCosts,
|
||||
OverheadPercent = pr.OverheadPercent,
|
||||
ProfitMargin = pr.ProfitMargin,
|
||||
ProfitPercent = pr.ProfitPercent,
|
||||
SubtotalBeforeDiscount = pr.SubtotalBeforeDiscount,
|
||||
PricingTierDiscountAmount = pr.PricingTierDiscountAmount,
|
||||
PricingTierDiscountPercent = pr.PricingTierDiscountPercent,
|
||||
QuoteDiscountAmount = pr.QuoteDiscountAmount,
|
||||
QuoteDiscountPercent = pr.QuoteDiscountPercent,
|
||||
DiscountAmount = pr.DiscountAmount,
|
||||
DiscountPercent = pr.DiscountPercent,
|
||||
SubtotalAfterDiscount = pr.SubtotalAfterDiscount,
|
||||
RushFee = pr.RushFee,
|
||||
TaxAmount = pr.TaxAmount,
|
||||
TaxPercent = pr.TaxPercent,
|
||||
Total = pr.Total
|
||||
};
|
||||
|
||||
#endregion
|
||||
|
||||
#region Item Pricing (AJAX)
|
||||
@@ -3322,8 +3368,7 @@ public class JobsController : Controller
|
||||
public async Task<IActionResult> GetTimeEntries(int jobId)
|
||||
{
|
||||
var entries = await _unitOfWork.JobTimeEntries.FindAsync(
|
||||
e => e.JobId == jobId, false,
|
||||
e => e.Worker); // Worker nav loaded for display of legacy entries that pre-date user migration
|
||||
e => e.JobId == jobId, false);
|
||||
var dtos = _mapper.Map<List<JobTimeEntryDto>>(entries.OrderByDescending(e => e.WorkDate).ToList());
|
||||
return Json(dtos);
|
||||
}
|
||||
@@ -3777,15 +3822,24 @@ public class JobsController : Controller
|
||||
|
||||
// Operating costs for fallback labor rate and oven rate
|
||||
var opCosts = (await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == companyId)).FirstOrDefault();
|
||||
var fallbackLaborRate = opCosts?.StandardLaborRate ?? 0m;
|
||||
var effectiveOvenMinutes = (opCosts?.DefaultOvenCycleMinutes > 0 ? (int?)opCosts!.DefaultOvenCycleMinutes : null) ?? 45;
|
||||
var defaultOvenCycleHours = effectiveOvenMinutes / 60.0m;
|
||||
|
||||
// Role cost rates map: role → hourly rate
|
||||
var roleCosts = await _unitOfWork.ShopWorkerRoleCosts.FindAsync(r => r.CompanyId == companyId);
|
||||
var roleCostMap = roleCosts.ToDictionary(r => r.Role, r => r.HourlyRate);
|
||||
// Labor cost rate priority: per-user LaborCostPerHour → company LaborCostPerHour → 20% of StandardLaborRate
|
||||
var companyLaborCostRate = opCosts?.LaborCostPerHour ?? ((opCosts?.StandardLaborRate ?? 0m) * 0.20m);
|
||||
var companyUsers = await _userManager.Users
|
||||
.Where(u => u.CompanyId == companyId && u.LaborCostPerHour != null)
|
||||
.Select(u => new { u.Id, u.LaborCostPerHour })
|
||||
.ToListAsync();
|
||||
var userLaborCostMap = companyUsers.ToDictionary(u => u.Id, u => u.LaborCostPerHour!.Value);
|
||||
|
||||
// 1. Powder / Material cost
|
||||
// Priority: PowderUsageLog actuals (sum per coat) > coat.ActualPowderUsedLbs > coat.PowderToOrder (estimated)
|
||||
var usageLogs = await _unitOfWork.PowderUsageLogs.FindAsync(u => u.JobId == jobId);
|
||||
var actualByCoat = usageLogs
|
||||
.GroupBy(u => u.JobItemCoatId)
|
||||
.ToDictionary(g => g.Key, g => g.Sum(u => u.ActualLbsUsed));
|
||||
|
||||
decimal powderCost = 0m;
|
||||
var powderLines = new List<object>();
|
||||
bool hasCoatsWithRateButNoQty = false;
|
||||
@@ -3793,7 +3847,19 @@ public class JobsController : Controller
|
||||
{
|
||||
foreach (var coat in item.Coats)
|
||||
{
|
||||
var lbs = coat.ActualPowderUsedLbs ?? coat.PowderToOrder ?? 0m;
|
||||
bool isActual;
|
||||
decimal lbs;
|
||||
if (actualByCoat.TryGetValue(coat.Id, out var loggedLbs) && loggedLbs > 0)
|
||||
{
|
||||
lbs = loggedLbs;
|
||||
isActual = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
lbs = coat.ActualPowderUsedLbs ?? coat.PowderToOrder ?? 0m;
|
||||
isActual = coat.ActualPowderUsedLbs.HasValue;
|
||||
}
|
||||
|
||||
var costPerLb = coat.PowderCostPerLb ?? 0m;
|
||||
var lineCost = lbs * costPerLb;
|
||||
powderCost += lineCost;
|
||||
@@ -3804,7 +3870,7 @@ public class JobsController : Controller
|
||||
lbs = Math.Round(lbs, 3),
|
||||
costPerLb = Math.Round(costPerLb, 4),
|
||||
total = Math.Round(lineCost, 2),
|
||||
isActual = coat.ActualPowderUsedLbs.HasValue
|
||||
isActual
|
||||
});
|
||||
}
|
||||
else if (costPerLb > 0 && lbs == 0)
|
||||
@@ -3816,20 +3882,23 @@ public class JobsController : Controller
|
||||
}
|
||||
|
||||
// 2. Labor cost
|
||||
// Priority: per-user LaborCostPerHour → company LaborCostPerHour → 20% of StandardLaborRate
|
||||
decimal laborCost = 0m;
|
||||
var laborLines = new List<object>();
|
||||
foreach (var entry in job.TimeEntries)
|
||||
{
|
||||
var rate = entry.Worker != null && roleCostMap.TryGetValue(entry.Worker.Role, out var r) ? r : fallbackLaborRate;
|
||||
bool usingPerUser = entry.UserId != null && userLaborCostMap.TryGetValue(entry.UserId, out _);
|
||||
var rate = usingPerUser
|
||||
? userLaborCostMap[entry.UserId!]
|
||||
: companyLaborCostRate;
|
||||
var lineCost = entry.HoursWorked * rate;
|
||||
laborCost += lineCost;
|
||||
laborLines.Add(new {
|
||||
worker = entry.Worker?.Name ?? "Unknown",
|
||||
role = entry.Worker != null ? System.Text.RegularExpressions.Regex.Replace(entry.Worker.Role.ToString(), "([a-z])([A-Z])", "$1 $2") : "",
|
||||
worker = entry.UserDisplayName ?? "Unknown",
|
||||
hours = entry.HoursWorked,
|
||||
rate = Math.Round(rate, 2),
|
||||
total = Math.Round(lineCost, 2),
|
||||
usingFallback = entry.Worker == null || !roleCostMap.ContainsKey(entry.Worker.Role),
|
||||
usingFallback = !usingPerUser,
|
||||
stage = entry.Stage,
|
||||
workDate = entry.WorkDate.ToString("MM/dd/yyyy")
|
||||
});
|
||||
@@ -3903,7 +3972,7 @@ public class JobsController : Controller
|
||||
grossMargin,
|
||||
quotedMargin,
|
||||
quotedPrice = Math.Round(job.QuotedPrice, 2),
|
||||
fallbackLaborRate,
|
||||
companyLaborCostRate,
|
||||
powderLines,
|
||||
laborLines,
|
||||
hasPowderData = powderLines.Count > 0,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using AutoMapper;
|
||||
using System.Text.Json;
|
||||
using AutoMapper;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -2847,8 +2848,39 @@ public class QuotesController : Controller
|
||||
JobPriorityId = selectedPriority?.Id ?? 1,
|
||||
QuotedPrice = quote.Total,
|
||||
FinalPrice = quote.Total,
|
||||
OvenBatchCost = quote.OvenBatchCost,
|
||||
ShopSuppliesAmount = quote.ShopSuppliesAmount,
|
||||
ShopSuppliesPercent = quote.ShopSuppliesPercent,
|
||||
PricingBreakdownJson = JsonSerializer.Serialize(new QuotePricingBreakdownDto
|
||||
{
|
||||
MaterialCosts = quote.MaterialCosts,
|
||||
LaborCosts = quote.LaborCosts,
|
||||
EquipmentCosts = quote.EquipmentCosts,
|
||||
ItemsSubtotal = quote.ItemsSubtotal,
|
||||
OvenBatchCost = quote.OvenBatchCost,
|
||||
OvenBatches = quote.OvenBatches,
|
||||
OvenCycleMinutes = quote.OvenCycleMinutes ?? 0,
|
||||
FacilityOverheadCost = quote.FacilityOverheadCost,
|
||||
FacilityOverheadRatePerHour = quote.FacilityOverheadRatePerHour,
|
||||
ShopSuppliesAmount = quote.ShopSuppliesAmount,
|
||||
ShopSuppliesPercent = quote.ShopSuppliesPercent,
|
||||
OverheadCosts = quote.OverheadAmount,
|
||||
OverheadPercent = quote.OverheadPercent,
|
||||
ProfitMargin = quote.ProfitMargin,
|
||||
ProfitPercent = quote.ProfitPercent,
|
||||
SubtotalBeforeDiscount = quote.SubTotal,
|
||||
PricingTierDiscountAmount = quote.PricingTierDiscountAmount,
|
||||
PricingTierDiscountPercent = quote.PricingTierDiscountPercent,
|
||||
QuoteDiscountAmount = quote.QuoteDiscountAmount,
|
||||
QuoteDiscountPercent = quote.QuoteDiscountPercent,
|
||||
DiscountAmount = quote.DiscountAmount,
|
||||
DiscountPercent = quote.DiscountPercent,
|
||||
SubtotalAfterDiscount = quote.SubtotalAfterDiscount,
|
||||
RushFee = quote.RushFee,
|
||||
TaxAmount = quote.TaxAmount,
|
||||
TaxPercent = quote.TaxPercent,
|
||||
Total = quote.Total
|
||||
}),
|
||||
CustomerPO = quote.CustomerPO,
|
||||
InternalNotes = quote.Notes, // Copy internal notes from quote
|
||||
IsCustomerApproved = true,
|
||||
|
||||
@@ -911,7 +911,7 @@ public class ToolsController : Controller
|
||||
/// <c>CompanyId</c> provides the multi-tenant isolation that global query filters would
|
||||
/// normally enforce for other entity types.
|
||||
/// </summary>
|
||||
// GET: Tools/GetShopWorkers - For randomizer wheel
|
||||
// GET: Tools/GetShopWorkers - Returns active company users for randomizer wheel
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetShopWorkers()
|
||||
{
|
||||
|
||||
@@ -1219,7 +1219,6 @@ public static class HelpKnowledgeBase
|
||||
- [Accounts Payable](/Help/AccountsPayable)
|
||||
- [Equipment & Maintenance](/Help/Equipment)
|
||||
- [Vendors](/Help/Vendors)
|
||||
- [Shop Workers](/Help/ShopWorkers)
|
||||
- [Reports](/Help/Reports)
|
||||
- [Settings](/Help/Settings)
|
||||
- [User Profile](/Help/UserProfile)
|
||||
|
||||
@@ -270,8 +270,7 @@ builder.Services.AddSingleton<IMapper>(sp =>
|
||||
cfg.AddProfile(new InventoryProfile());
|
||||
cfg.AddProfile(new EquipmentProfile());
|
||||
cfg.AddProfile(new MaintenanceProfile());
|
||||
cfg.AddProfile(new ShopWorkerProfile());
|
||||
cfg.AddProfile(new CatalogProfile());
|
||||
cfg.AddProfile(new CatalogProfile());
|
||||
cfg.AddProfile(new VendorProfile());
|
||||
cfg.AddProfile(new LookupProfile());
|
||||
cfg.AddProfile(new AppointmentProfile());
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@model PowderCoating.Application.DTOs.Company.CompanySettingsDto
|
||||
@model PowderCoating.Application.DTOs.Company.CompanySettingsDto
|
||||
@{
|
||||
ViewData["Title"] = "Company Settings";
|
||||
ViewData["PageIcon"] = "bi-building";
|
||||
@@ -375,6 +375,18 @@
|
||||
<input type="number" step="0.01" class="form-control" id="standardLaborRate" name="StandardLaborRate" value="@(Model.OperatingCosts?.StandardLaborRate ?? 0)" min="0" max="10000" required>
|
||||
<span class="input-group-text">/hr</span>
|
||||
</div>
|
||||
<small class="text-muted">Billing rate used in quotes and pricing</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="mb-3">
|
||||
<label for="laborCostPerHour" class="form-label">Shop Labor Cost Rate</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">$</span>
|
||||
<input type="number" step="0.01" class="form-control" id="laborCostPerHour" name="LaborCostPerHour" value="@(Model.OperatingCosts?.LaborCostPerHour?.ToString() ?? "")" min="0" max="10000" placeholder="@(((Model.OperatingCosts?.StandardLaborRate ?? 0) * 0.20m).ToString("0.00"))">
|
||||
<span class="input-group-text">/hr</span>
|
||||
</div>
|
||||
<small class="text-muted">Actual wage cost for job costing & profit display only — never shown to customers. Leave blank to default to 20% of billing rate.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
@@ -516,35 +528,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Role-Based Labor Rates -->
|
||||
<h6 class="border-bottom pb-2 mb-3 mt-4">Role-Based Labor Cost Rates
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right"
|
||||
data-bs-title="Role-Based Labor Cost Rates"
|
||||
data-bs-content="Set an optional cost rate per worker role for job profitability calculations. These are your <strong>internal cost rates</strong> (what you pay), not what you bill customers. If a rate is left blank, the <strong>Standard Labor Rate</strong> above is used as the fallback.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</h6>
|
||||
<p class="text-muted small">Used for job costing only — not shown to customers. Leave blank to use the Standard Labor Rate.</p>
|
||||
<div class="table-responsive mb-3">
|
||||
<table class="table table-sm align-middle" id="roleCostTable">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Role</th>
|
||||
<th style="width:180px;">Cost Rate / hr</th>
|
||||
<th style="width:140px;" class="text-muted small">Fallback if blank</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="roleCostBody">
|
||||
<tr><td colspan="3" class="text-center text-muted py-2"><div class="spinner-border spinner-border-sm me-2"></div>Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-primary" onclick="saveRoleCosts()">
|
||||
<i class="bi bi-floppy me-1"></i>Save Labor Rates
|
||||
</button>
|
||||
<span id="roleCostSaveStatus" class="ms-2 small"></span>
|
||||
|
||||
<!-- Pricing & Overhead -->
|
||||
<h6 class="border-bottom pb-2 mb-3 mt-4">Pricing & Profit
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
@@ -2949,76 +2932,6 @@
|
||||
loadOvenCosts();
|
||||
});
|
||||
|
||||
// Reload role costs whenever the Operating Costs tab is shown
|
||||
document.getElementById('operating-costs-tab')?.addEventListener('shown.bs.tab', () => {
|
||||
loadRoleCosts();
|
||||
});
|
||||
|
||||
// If Equipment Profile tab is already active on page load, load immediately
|
||||
if (document.getElementById('quoting-calibration')?.classList.contains('show')) {
|
||||
loadOvenCosts();
|
||||
}
|
||||
|
||||
// If Operating Costs tab is already active on page load, load role costs immediately
|
||||
if (document.getElementById('operating-costs')?.classList.contains('show')) {
|
||||
loadRoleCosts();
|
||||
}
|
||||
|
||||
// ── Role-Based Labor Cost Rates ────────────────────────────────────────
|
||||
const ROLE_NAMES = ['General Labor','Sandblaster','Coater','Masker','Quality Control','Oven Operator','Supervisor','Maintenance'];
|
||||
|
||||
async function loadRoleCosts() {
|
||||
const resp = await fetch('/CompanySettings/GetRoleCosts');
|
||||
const saved = await resp.json(); // [{role, hourlyRate}]
|
||||
const rateMap = {};
|
||||
saved.forEach(r => rateMap[r.role] = r.hourlyRate);
|
||||
|
||||
const fallbackEl = document.getElementById('standardLaborRate');
|
||||
const fallback = fallbackEl ? `$${parseFloat(fallbackEl.value || 0).toFixed(2)}/hr` : 'Standard Rate';
|
||||
|
||||
const tbody = document.getElementById('roleCostBody');
|
||||
tbody.innerHTML = ROLE_NAMES.map((name, i) => `
|
||||
<tr>
|
||||
<td><span class="badge bg-secondary">${name}</span></td>
|
||||
<td>
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text">$</span>
|
||||
<input type="number" step="0.01" min="0" max="999"
|
||||
class="form-control role-cost-input"
|
||||
data-role="${i}"
|
||||
value="${rateMap[i] > 0 ? rateMap[i] : ''}"
|
||||
placeholder="(use default)">
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-muted small">${fallback}</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
async function saveRoleCosts() {
|
||||
const inputs = document.querySelectorAll('.role-cost-input');
|
||||
const rates = Array.from(inputs).map(el => ({
|
||||
role: parseInt(el.dataset.role),
|
||||
hourlyRate: parseFloat(el.value) || 0
|
||||
}));
|
||||
const statusEl = document.getElementById('roleCostSaveStatus');
|
||||
statusEl.textContent = 'Saving...';
|
||||
statusEl.className = 'ms-2 small text-muted';
|
||||
const resp = await fetch('/CompanySettings/SaveRoleCosts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '' },
|
||||
body: JSON.stringify(rates)
|
||||
});
|
||||
const result = await resp.json();
|
||||
if (result.success) {
|
||||
statusEl.textContent = '✓ Saved';
|
||||
statusEl.className = 'ms-2 small text-success';
|
||||
setTimeout(() => statusEl.textContent = '', 3000);
|
||||
} else {
|
||||
statusEl.textContent = result.message || 'Error saving';
|
||||
statusEl.className = 'ms-2 small text-danger';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Quote PDF Template ──────────────────────────────────────────────
|
||||
function syncColorPicker(hex) {
|
||||
if (/^#[0-9A-Fa-f]{6}$/.test(hex)) {
|
||||
|
||||
@@ -106,6 +106,16 @@
|
||||
<input asp-for="Position" class="form-control" />
|
||||
<span asp-validation-for="Position" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label asp-for="LaborCostPerHour" class="form-label">Labor Cost Rate</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">$</span>
|
||||
<input asp-for="LaborCostPerHour" type="number" step="0.01" min="0" max="10000" class="form-control" placeholder="Use company default" />
|
||||
<span class="input-group-text">/hr</span>
|
||||
</div>
|
||||
<span asp-validation-for="LaborCostPerHour" class="text-danger"></span>
|
||||
<small class="text-muted">Used for internal job costing only — never shown to customers. Overrides the company default when set. Leave blank to use the shop-wide rate.</small>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label asp-for="HireDate" class="form-label">Hire Date</label>
|
||||
<input asp-for="HireDate" class="form-control" type="date" />
|
||||
|
||||
@@ -189,22 +189,6 @@
|
||||
<!-- Shop Management -->
|
||||
<h2 class="h6 fw-semibold mb-2 text-muted text-uppercase" style="letter-spacing:.05em; font-size:.7rem;">Shop Management</h2>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-start gap-3">
|
||||
<div class="rounded-3 bg-info bg-opacity-10 p-2 flex-shrink-0">
|
||||
<i class="bi bi-person-badge text-info fs-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="card-title mb-1">Shop Workers</h5>
|
||||
<p class="card-text text-muted small mb-2">Add floor staff, assign roles like Coater or Sandblaster, and link workers to jobs and maintenance tasks.</p>
|
||||
<a asp-controller="Help" asp-action="ShopWorkers" class="btn btn-sm btn-outline-info">Read more <i class="bi bi-arrow-right ms-1"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
@{
|
||||
ViewData["Title"] = "Shop Workers";
|
||||
}
|
||||
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<a asp-controller="Help" asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-0">
|
||||
<li class="breadcrumb-item"><a asp-controller="Help" asp-action="Index">Help</a></li>
|
||||
<li class="breadcrumb-item active">Shop Workers</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-9">
|
||||
|
||||
<section id="overview" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-info-circle text-primary me-2"></i>Overview
|
||||
</h2>
|
||||
<p>
|
||||
Shop Workers are the people who do the hands-on work in your facility — sandblasters, coaters,
|
||||
maskers, oven operators, and supervisors. Adding your workers to the system lets you assign them
|
||||
to jobs and maintenance tasks, giving you a clear picture of who is working on what at any time.
|
||||
</p>
|
||||
<p>
|
||||
Shop Workers are separate from system user accounts. A worker does not need to log into the
|
||||
system — they are simply a record that can be assigned to work. If a worker also needs to log
|
||||
in and update job statuses themselves, an Administrator can create a linked user account for
|
||||
them with the <em>Shop Floor</em> role.
|
||||
</p>
|
||||
<p>
|
||||
Find Shop Workers under <strong>Operations › Shop Workers</strong> in the sidebar.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="adding-a-worker" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-person-plus text-primary me-2"></i>Adding a Worker
|
||||
</h2>
|
||||
<p>To add a new shop worker:</p>
|
||||
<ol class="mb-3">
|
||||
<li class="mb-2">Go to <strong>Operations › Shop Workers</strong> and click <strong>New Worker</strong>.</li>
|
||||
<li class="mb-2">
|
||||
Fill in the worker's details:
|
||||
<ul class="mt-1">
|
||||
<li><strong>Name</strong> — the worker's full name as it should appear on job assignments.</li>
|
||||
<li><strong>Role</strong> — select the role that best describes their primary function (see below).</li>
|
||||
<li><strong>Phone</strong> — optional, useful for supervisors to have on file.</li>
|
||||
<li><strong>Email</strong> — optional, used if the worker also has a system login.</li>
|
||||
<li><strong>Notes</strong> — any relevant information, such as certifications, shift preferences, or specialties.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="mb-2">Ensure <strong>Active</strong> is checked (it is on by default).</li>
|
||||
<li class="mb-2">Click <strong>Save Worker</strong>.</li>
|
||||
</ol>
|
||||
<p>
|
||||
Once saved, the worker will appear in the assignment dropdowns on the Job Create and Edit forms.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="worker-roles" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-tags text-primary me-2"></i>Worker Roles
|
||||
</h2>
|
||||
<p>
|
||||
Each worker is assigned one of the following roles. The role is a label — it helps you pick the
|
||||
right person for a job but does not restrict what a worker can be assigned to.
|
||||
</p>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:25%">Role</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><span class="badge bg-secondary">General Labor</span></td>
|
||||
<td>
|
||||
Versatile workers who assist across multiple areas of the shop — loading and unloading,
|
||||
racking parts, clean-up, and general support tasks. Not specialized in a single process.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="badge bg-warning text-dark">Sandblaster</span></td>
|
||||
<td>
|
||||
Operates the sandblasting or media-blasting equipment to prepare metal surfaces for
|
||||
coating. Responsible for achieving the correct surface profile and ensuring all rust,
|
||||
paint, and contamination is removed.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="badge bg-primary">Coater</span></td>
|
||||
<td>
|
||||
Applies powder coating using an electrostatic spray gun. Responsible for even coverage,
|
||||
correct mil thickness, and minimizing overspray and waste. Often the most skilled
|
||||
technical role on the floor.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="badge bg-info text-dark">Masker</span></td>
|
||||
<td>
|
||||
Applies masking tape, plugs, and caps to protect threads, bearing surfaces, and areas
|
||||
that must not be coated. Attention to detail is critical — missed masking means rework.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="badge bg-success">Quality Control</span></td>
|
||||
<td>
|
||||
Inspects finished parts for adhesion, color consistency, coverage, and surface defects
|
||||
before the job is marked as complete. May also handle pre-coat inspection after
|
||||
sandblasting.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="badge bg-danger">Oven Operator</span></td>
|
||||
<td>
|
||||
Loads parts into the curing oven, sets correct temperatures and cure times for the
|
||||
powder being used, monitors the cure cycle, and unloads parts safely after cooling.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="badge bg-dark">Supervisor</span></td>
|
||||
<td>
|
||||
Oversees day-to-day shop floor operations, assigns tasks to other workers, ensures
|
||||
jobs are progressing on schedule, and handles escalations. May also handle customer
|
||||
communication for production updates.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="badge bg-secondary">Maintenance</span></td>
|
||||
<td>
|
||||
Responsible for keeping equipment running — performing scheduled preventive maintenance,
|
||||
troubleshooting breakdowns, and coordinating with external service technicians when
|
||||
needed.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="assigning-to-jobs" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-briefcase text-primary me-2"></i>Assigning Workers to Jobs
|
||||
</h2>
|
||||
<p>
|
||||
Each job can have one worker assigned to it as the primary responsible person. This is the
|
||||
worker who owns the job from start to finish — typically a coater or supervisor.
|
||||
</p>
|
||||
<p>To assign a worker when creating or editing a job:</p>
|
||||
<ol class="mb-3">
|
||||
<li class="mb-1">Open the job's Create or Edit form.</li>
|
||||
<li class="mb-1">Scroll down to the <strong>Assignment</strong> section.</li>
|
||||
<li class="mb-1">Select a worker from the <strong>Assigned Worker</strong> dropdown. Only active workers are listed.</li>
|
||||
<li class="mb-1">Save the job.</li>
|
||||
</ol>
|
||||
<p>
|
||||
The assigned worker's name appears on the job list view, on the job detail page, and in any
|
||||
reports filtered by worker.
|
||||
</p>
|
||||
<p>
|
||||
Workers can also be assigned to <strong>maintenance tasks</strong> on equipment. See the
|
||||
<a asp-controller="Help" asp-action="Equipment" class="text-decoration-none">Equipment & Maintenance</a>
|
||||
help page for details.
|
||||
</p>
|
||||
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
|
||||
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
|
||||
<div>
|
||||
If a worker you want to assign does not appear in the dropdown, check that their record is
|
||||
marked as <strong>Active</strong>. Inactive workers are hidden from assignment lists.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="deactivating-a-worker" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-person-dash text-primary me-2"></i>Deactivating a Worker
|
||||
</h2>
|
||||
<p>
|
||||
When a worker leaves the shop or is no longer available for assignment, deactivate their record
|
||||
rather than deleting it. Deactivating preserves the history of all jobs they were assigned to,
|
||||
while removing them from the active assignment dropdowns so they cannot be accidentally selected
|
||||
for new work.
|
||||
</p>
|
||||
<p>To deactivate a worker:</p>
|
||||
<ol class="mb-3">
|
||||
<li class="mb-1">Open the worker's Details or Edit page.</li>
|
||||
<li class="mb-1">Uncheck the <strong>Active</strong> checkbox.</li>
|
||||
<li class="mb-1">Click <strong>Save</strong>.</li>
|
||||
</ol>
|
||||
<p>
|
||||
Alternatively, use the <strong>Delete</strong> button on the Details page to perform a soft
|
||||
delete, which has the same effect.
|
||||
</p>
|
||||
<div class="alert alert-permanent alert-secondary d-flex gap-2 mb-0" role="alert">
|
||||
<i class="bi bi-info-circle flex-shrink-0 mt-1"></i>
|
||||
<div>
|
||||
If a worker currently has open jobs assigned to them, reassign those jobs first before
|
||||
deactivating the worker — so the jobs remain clearly owned and nothing falls through the cracks.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3 d-none d-lg-block">
|
||||
@{ await Html.RenderPartialAsync("_HelpNav"); }
|
||||
<div class="card border-0 shadow-sm sticky-top" style="top:80px">
|
||||
<div class="card-header bg-transparent fw-semibold small text-muted text-uppercase" style="letter-spacing:.05em; font-size:.7rem;">On this page</div>
|
||||
<div class="card-body p-0">
|
||||
<nav class="nav flex-column">
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#overview">Overview</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#adding-a-worker">Adding a Worker</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#worker-roles">Worker Roles</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#assigning-to-jobs">Assigning to Jobs</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#deactivating-a-worker">Deactivating a Worker</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -65,11 +65,7 @@
|
||||
<div class="px-3 pt-2 pb-1">
|
||||
<span class="text-muted text-uppercase" style="font-size:.65rem; letter-spacing:.07em; font-weight:600;">Shop Management</span>
|
||||
</div>
|
||||
<a class="nav-link py-2 px-3 d-flex align-items-center gap-2 @(currentAction == "ShopWorkers" ? "active fw-semibold text-primary" : "text-body")"
|
||||
asp-controller="Help" asp-action="ShopWorkers">
|
||||
<i class="bi bi-person-badge"></i> Shop Workers
|
||||
</a>
|
||||
<a class="nav-link py-2 px-3 d-flex align-items-center gap-2 @(currentAction == "Equipment" ? "active fw-semibold text-primary" : "text-body")"
|
||||
<a class="nav-link py-2 px-3 d-flex align-items-center gap-2 @(currentAction == "Equipment" ? "active fw-semibold text-primary" : "text-body")"
|
||||
asp-controller="Help" asp-action="Equipment">
|
||||
<i class="bi bi-tools"></i> Equipment & Maintenance
|
||||
</a>
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Shop work has started � review the quote and apply any changes manually.</span>
|
||||
<span>Shop work has started — review the quote and apply any changes manually.</span>
|
||||
}
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
@@ -217,7 +217,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<div id="scheduledDate-saving" class="d-none mt-1 small text-muted">
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>Saving�
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>Saving…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -263,7 +263,7 @@
|
||||
<i class="bi bi-x-circle me-1"></i><small>Clear date</small>
|
||||
</button>
|
||||
<div id="dueDate-saving" class="d-none mt-1 small text-muted">
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>Saving�
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>Saving…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -273,7 +273,7 @@
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<select id="workerAssignmentSelect" class="form-select form-select-sm"
|
||||
onchange="updateWorkerAssignment(this)">
|
||||
<option value="">� Unassigned �</option>
|
||||
<option value="">– Unassigned –</option>
|
||||
@foreach (var w in (IEnumerable<SelectListItem>)ViewBag.Workers)
|
||||
{
|
||||
if (w.Value == Model.AssignedUserId)
|
||||
@@ -287,7 +287,7 @@
|
||||
}
|
||||
</select>
|
||||
<span id="workerSaveIndicator" class="text-muted small d-none">
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>Saving�
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>Saving…
|
||||
</span>
|
||||
<span id="workerSavedTick" class="text-success small d-none">
|
||||
<i class="bi bi-check-circle-fill"></i>
|
||||
@@ -321,7 +321,7 @@
|
||||
|
||||
<div class="card-body">
|
||||
|
||||
@* ── Catalog Products ── *@
|
||||
@* -- Catalog Products -- *@
|
||||
@if (catalogItems.Any())
|
||||
{
|
||||
<h6 class="text-primary mb-3"><i class="bi bi-bag-check me-2"></i>Catalog Products</h6>
|
||||
@@ -351,10 +351,10 @@
|
||||
{
|
||||
<br />
|
||||
<small class="ms-3">
|
||||
� <strong>@coat.CoatName</strong>
|
||||
• <strong>@coat.CoatName</strong>
|
||||
@if (!string.IsNullOrEmpty(coat.ColorName))
|
||||
{
|
||||
<text> � @coat.ColorName</text>
|
||||
<text> – @coat.ColorName</text>
|
||||
@if (!string.IsNullOrEmpty(coat.VendorName))
|
||||
{
|
||||
<text> (@coat.VendorName)</text>
|
||||
@@ -373,7 +373,7 @@
|
||||
<span class="badge bg-info ms-1" style="font-size:.7em;">@coat.PowderToOrder.Value.ToString("0.##") lbs</span>
|
||||
@if (!coat.InventoryItemId.HasValue)
|
||||
{
|
||||
<span class="badge bg-warning text-dark ms-1" style="font-size:.7em;" title="Custom powder � must be purchased before coating"><i class="bi bi-cart me-1"></i>ORDER POWDER</span>
|
||||
<span class="badge bg-warning text-dark ms-1" style="font-size:.7em;" title="Custom powder — must be purchased before coating"><i class="bi bi-cart me-1"></i>ORDER POWDER</span>
|
||||
}
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(coat.Notes))
|
||||
@@ -390,7 +390,7 @@
|
||||
@foreach (var ps in item.PrepServices)
|
||||
{
|
||||
<br />
|
||||
<small class="ms-3">� <strong>@(ps.PrepServiceName ?? $"Service #{ps.PrepServiceId}")</strong> <span class="text-muted">� @ps.EstimatedMinutes min</span></small>
|
||||
<small class="ms-3">• <strong>@(ps.PrepServiceName ?? $"Service #{ps.PrepServiceId}")</strong> <span class="text-muted">– @ps.EstimatedMinutes min</span></small>
|
||||
}
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(item.Notes))
|
||||
@@ -414,7 +414,7 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ── Custom Work ── *@
|
||||
@* -- Custom Work -- *@
|
||||
@if (customItems.Any())
|
||||
{
|
||||
<h6 class="text-success mb-3"><i class="bi bi-calculator me-2"></i>Custom Work</h6>
|
||||
@@ -478,10 +478,10 @@
|
||||
{
|
||||
<br />
|
||||
<small class="ms-3">
|
||||
� <strong>@coat.CoatName</strong>
|
||||
• <strong>@coat.CoatName</strong>
|
||||
@if (!string.IsNullOrEmpty(coat.ColorName))
|
||||
{
|
||||
<text> � @coat.ColorName</text>
|
||||
<text> – @coat.ColorName</text>
|
||||
@if (!string.IsNullOrEmpty(coat.VendorName))
|
||||
{
|
||||
<text> (@coat.VendorName)</text>
|
||||
@@ -500,7 +500,7 @@
|
||||
<span class="badge bg-info ms-1" style="font-size:.7em;">@coat.PowderToOrder.Value.ToString("0.##") lbs</span>
|
||||
@if (!coat.InventoryItemId.HasValue)
|
||||
{
|
||||
<span class="badge bg-warning text-dark ms-1" style="font-size:.7em;" title="Custom powder � must be purchased before coating"><i class="bi bi-cart me-1"></i>ORDER POWDER</span>
|
||||
<span class="badge bg-warning text-dark ms-1" style="font-size:.7em;" title="Custom powder — must be purchased before coating"><i class="bi bi-cart me-1"></i>ORDER POWDER</span>
|
||||
}
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(coat.Notes))
|
||||
@@ -517,7 +517,7 @@
|
||||
@foreach (var ps in item.PrepServices)
|
||||
{
|
||||
<br />
|
||||
<small class="ms-3">� <strong>@(ps.PrepServiceName ?? $"Service #{ps.PrepServiceId}")</strong> <span class="text-muted">� @ps.EstimatedMinutes min</span></small>
|
||||
<small class="ms-3">• <strong>@(ps.PrepServiceName ?? $"Service #{ps.PrepServiceId}")</strong> <span class="text-muted">– @ps.EstimatedMinutes min</span></small>
|
||||
}
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(item.Notes))
|
||||
@@ -532,7 +532,7 @@
|
||||
<text>@item.SurfaceAreaSqFt.ToString("F2") @ViewBag.AreaUnit</text>
|
||||
<br /><small class="text-muted">per item</small>
|
||||
}
|
||||
else { <span class="text-muted">�</span> }
|
||||
else { <span class="text-muted">—</span> }
|
||||
</td>
|
||||
<td class="text-center">
|
||||
@if (item.EstimatedMinutes > 0)
|
||||
@@ -540,7 +540,7 @@
|
||||
<text>@item.EstimatedMinutes min</text>
|
||||
<br /><small class="text-muted">per item</small>
|
||||
}
|
||||
else { <span class="text-muted">�</span> }
|
||||
else { <span class="text-muted">—</span> }
|
||||
</td>
|
||||
<td class="text-center">
|
||||
@if (totalPowderNeeded > 0)
|
||||
@@ -548,7 +548,7 @@
|
||||
<strong class="text-success">@totalPowderNeeded.ToString("F2") lbs</strong>
|
||||
<br /><small class="text-muted">total batch</small>
|
||||
}
|
||||
else { <span class="text-muted">�</span> }
|
||||
else { <span class="text-muted">—</span> }
|
||||
</td>
|
||||
<td class="text-end">@item.UnitPrice.ToString("C")</td>
|
||||
<td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td>
|
||||
@@ -565,7 +565,7 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ── Labor ── *@
|
||||
@* -- Labor -- *@
|
||||
@if (laborItems.Any())
|
||||
{
|
||||
<h6 class="text-warning mb-3"><i class="bi bi-person-gear me-2"></i>Labor</h6>
|
||||
@@ -599,7 +599,7 @@
|
||||
{
|
||||
<text>@item.EstimatedMinutes min</text>
|
||||
}
|
||||
else { <span class="text-muted">�</span> }
|
||||
else { <span class="text-muted">—</span> }
|
||||
</td>
|
||||
<td class="text-end">@item.UnitPrice.ToString("C")</td>
|
||||
<td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td>
|
||||
@@ -616,7 +616,7 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ── Mobile cards ── *@
|
||||
@* -- Mobile cards -- *@
|
||||
<div class="d-lg-none mt-2">
|
||||
@foreach (var item in Model.Items)
|
||||
{
|
||||
@@ -653,7 +653,7 @@
|
||||
<span class="mobile-card-value">
|
||||
@foreach (var coat in item.Coats.OrderBy(c => c.Sequence))
|
||||
{
|
||||
<small class="d-block">@coat.CoatName@(!string.IsNullOrEmpty(coat.ColorName) ? $" � {coat.ColorName}" : "")</small>
|
||||
<small class="d-block">@coat.CoatName@if (!string.IsNullOrEmpty(coat.ColorName)) { <text> – @coat.ColorName</text> }</small>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
@@ -704,7 +704,7 @@
|
||||
<i class="bi bi-chevron-down collapse-chevron ms-1" style="transition:transform .2s;"></i>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<span class="text-muted small">Total: <strong id="totalHoursDisplay">�</strong></span>
|
||||
<span class="text-muted small">Total: <strong id="totalHoursDisplay">—</strong></span>
|
||||
@{
|
||||
var estimatedMins = Model.Items?.Sum(i => i.EstimatedMinutes * i.Quantity) ?? 0;
|
||||
var estimatedHrs = estimatedMins / 60m;
|
||||
@@ -741,7 +741,7 @@
|
||||
<tfoot class="table-light fw-semibold">
|
||||
<tr>
|
||||
<td colspan="3">Total</td>
|
||||
<td class="text-end" id="timeEntriesTotalHours">�</td>
|
||||
<td class="text-end" id="timeEntriesTotalHours">—</td>
|
||||
<td colspan="3"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
@@ -1099,7 +1099,7 @@
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="intakeModalLabel">
|
||||
<i class="bi bi-box-seam me-2 text-info"></i>Part Intake � Check In
|
||||
<i class="bi bi-box-seam me-2 text-info"></i>Part Intake – Check In
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
@@ -1117,7 +1117,7 @@
|
||||
value="@(Model.IntakePartCount.HasValue ? Model.IntakePartCount.Value.ToString() : "")"
|
||||
placeholder="@intakeExpectedCount" />
|
||||
<div id="intakeMismatchAlert" class="alert alert-warning alert-permanent mt-2 py-2 d-none">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>Count doesn't match expected � note the discrepancy below.
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>Count doesn't match expected — note the discrepancy below.
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
@@ -1310,7 +1310,7 @@
|
||||
<a asp-action="Intake" asp-route-id="@Model.Id"
|
||||
class="btn @(Model.IntakeDate.HasValue ? "btn-outline-secondary" : "btn-outline-info")"
|
||||
title="@(Model.IntakeDate.HasValue ? "Update part intake record" : "Check in parts for this job")">
|
||||
<i class=�bi bi-box-seam me-2�></i>@(Model.IntakeDate.HasValue ? "Intake ?" : "Intake")
|
||||
<i class="bi bi-box-seam me-2"></i>@Html.Raw(Model.IntakeDate.HasValue ? "Intake ✓" : "Intake")
|
||||
</a>
|
||||
}
|
||||
@{
|
||||
@@ -1368,7 +1368,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pricing Summary (internal � d-print-none) -->
|
||||
<!-- Pricing Summary (internal - d-print-none) -->
|
||||
@{
|
||||
var jobPb = ViewBag.JobPricingBreakdown as PowderCoating.Application.DTOs.Quote.QuotePricingBreakdownDto;
|
||||
}
|
||||
@@ -1400,7 +1400,7 @@
|
||||
@if (jobPb.OvenBatchCost > 0)
|
||||
{
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span><i class="bi bi-fire me-1"></i>Oven (@jobPb.OvenBatches batch@(jobPb.OvenBatches != 1 ? "es" : "")@(jobPb.OvenCycleMinutes > 0 ? $" � {jobPb.OvenCycleMinutes} min" : "")):</span>
|
||||
<span><i class="bi bi-fire me-1"></i>Oven (@jobPb.OvenBatches batch@(jobPb.OvenBatches != 1 ? "es" : "")@(jobPb.OvenCycleMinutes > 0 ? $" × {jobPb.OvenCycleMinutes} min" : "")):</span>
|
||||
<strong>@jobPb.OvenBatchCost.ToString("C")</strong>
|
||||
</div>
|
||||
}
|
||||
@@ -1518,7 +1518,7 @@
|
||||
}
|
||||
else if (allCatalog)
|
||||
{
|
||||
<div class="text-muted small fst-italic">All items use fixed catalog pricing � no per-category cost split available.</div>
|
||||
<div class="text-muted small fst-italic">All items use fixed catalog pricing — no per-category cost split available.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -1547,7 +1547,7 @@
|
||||
@if (jobPb.FacilityOverheadCost > 0)
|
||||
{
|
||||
<div class="d-flex justify-content-between small mb-1">
|
||||
<span class="text-muted">Facility overhead (@jobPb.FacilityOverheadRatePerHour.ToString("C2")/hr � estimated hours)</span>
|
||||
<span class="text-muted">Facility overhead (@jobPb.FacilityOverheadRatePerHour.ToString("C2")/hr × estimated hours)</span>
|
||||
<span>@jobPb.FacilityOverheadCost.ToString("C")</span>
|
||||
</div>
|
||||
}
|
||||
@@ -1712,11 +1712,11 @@
|
||||
<div class="px-3 pt-3 pb-2">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<span class="text-muted small">Revenue <span id="costingRevenueSource" class="badge bg-light text-secondary ms-1"></span></span>
|
||||
<span class="fw-semibold" id="costingRevenue">�</span>
|
||||
<span class="fw-semibold" id="costingRevenue">—</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between small text-muted mb-1 ps-2">
|
||||
<span>Powder / Materials <a href="#" class="text-muted ms-1" onclick="costing.toggleDetail('powder');return false;"><i class="bi bi-chevron-down" id="powderChevron"></i></a></span>
|
||||
<span id="costingPowder">�</span>
|
||||
<span id="costingPowder">—</span>
|
||||
</div>
|
||||
<div id="powderDetail" style="display:none;" class="ps-3 pb-1">
|
||||
<table class="table table-sm table-borderless mb-0" style="font-size:0.78rem;">
|
||||
@@ -1725,7 +1725,7 @@
|
||||
</div>
|
||||
<div class="d-flex justify-content-between small text-muted mb-1 ps-2">
|
||||
<span>Labor (<span id="costingLaborHours">0</span> hrs) <a href="#" class="text-muted ms-1" onclick="costing.toggleDetail('labor');return false;"><i class="bi bi-chevron-down" id="laborChevron"></i></a></span>
|
||||
<span id="costingLabor">�</span>
|
||||
<span id="costingLabor">—</span>
|
||||
</div>
|
||||
<div id="laborDetail" style="display:none;" class="ps-3 pb-1">
|
||||
<table class="table table-sm table-borderless mb-0" style="font-size:0.78rem;">
|
||||
@@ -1734,12 +1734,12 @@
|
||||
</div>
|
||||
<div class="d-flex justify-content-between small text-muted mb-1 ps-2">
|
||||
<span>Oven / Equipment <span id="costingOvenLabel" class="text-muted"></span></span>
|
||||
<span id="costingOven">�</span>
|
||||
<span id="costingOven">—</span>
|
||||
</div>
|
||||
<div id="costingReworkSection" style="display:none;">
|
||||
<div class="d-flex justify-content-between small text-muted mb-1 ps-2">
|
||||
<span>Rework Costs <a href="#" class="text-muted ms-1" onclick="costing.toggleDetail('rework');return false;"><i class="bi bi-chevron-down" id="reworkChevron"></i></a></span>
|
||||
<span id="costingRework">�</span>
|
||||
<span id="costingRework">—</span>
|
||||
</div>
|
||||
<div id="reworkDetail" style="display:none;" class="ps-3 pb-1">
|
||||
<table class="table table-sm table-borderless mb-0" style="font-size:0.78rem;">
|
||||
@@ -1748,25 +1748,25 @@
|
||||
</div>
|
||||
<div class="d-flex justify-content-between small text-success mb-1 ps-2">
|
||||
<span>Billed to Customer</span>
|
||||
<span id="costingReworkBilled">�</span>
|
||||
<span id="costingReworkBilled">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-2" />
|
||||
<div class="d-flex justify-content-between small mb-1 ps-2">
|
||||
<span class="text-muted">Total Costs</span>
|
||||
<span id="costingTotal" class="text-danger">�</span>
|
||||
<span id="costingTotal" class="text-danger">—</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between fw-bold mb-1">
|
||||
<span>Gross Profit</span>
|
||||
<span id="costingProfit">�</span>
|
||||
<span id="costingProfit">—</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between small text-muted mb-1">
|
||||
<span>Gross Margin</span>
|
||||
<span id="costingMargin">�</span>
|
||||
<span id="costingMargin">—</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between small text-muted">
|
||||
<span>Margin vs Quote</span>
|
||||
<span id="costingQuotedMargin">�</span>
|
||||
<span id="costingQuotedMargin">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="costingNotes" class="px-3 pb-3" style="font-size:0.75rem;"></div>
|
||||
@@ -1869,7 +1869,7 @@
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Tags
|
||||
<small class="text-muted fw-normal ms-1">� colors, finish, or other keywords</small>
|
||||
<small class="text-muted fw-normal ms-1">– colors, finish, or other keywords</small>
|
||||
</label>
|
||||
<input type="hidden" id="photoTagsHidden" name="tags" />
|
||||
<div id="photoTagsContainer"></div>
|
||||
@@ -1948,7 +1948,7 @@
|
||||
<textarea class="form-control" id="editPhotoCaption" rows="2" placeholder="Add a description or note..."></textarea>
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
<label class="form-label fw-semibold">Tags <small class="text-muted fw-normal ms-1">� colors, finish, keywords</small></label>
|
||||
<label class="form-label fw-semibold">Tags <small class="text-muted fw-normal ms-1">– colors, finish, keywords</small></label>
|
||||
<input type="hidden" id="editPhotoTagsHidden" />
|
||||
<div id="editPhotoTagsContainer"></div>
|
||||
</div>
|
||||
@@ -2000,7 +2000,7 @@
|
||||
<div class="mb-2">
|
||||
<label class="form-label fw-semibold" for="smsMessageText">Message</label>
|
||||
<textarea class="form-control" id="smsMessageText" rows="5"
|
||||
placeholder="Type your message�" maxlength="160"></textarea>
|
||||
placeholder="Type your message…" maxlength="160"></textarea>
|
||||
<div class="d-flex justify-content-between mt-1">
|
||||
<div id="smsStopWarning" class="text-warning small d-none">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>"Reply STOP to opt out." will be appended automatically.
|
||||
@@ -2012,7 +2012,7 @@
|
||||
</div>
|
||||
<div class="modal-footer justify-content-between">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal" id="smsDismissBtn">
|
||||
Skip � don't send
|
||||
Skip — don't send
|
||||
</button>
|
||||
<button type="button" class="btn btn-info text-white" id="smsSendBtn">
|
||||
<i class="bi bi-send me-1"></i>Send SMS
|
||||
@@ -2133,7 +2133,7 @@
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Specific Item (optional)</label>
|
||||
<select class="form-select" id="rwJobItem">
|
||||
<option value="">� Whole Job �</option>
|
||||
<option value="">– Whole Job –</option>
|
||||
@if (Model.Items != null)
|
||||
{
|
||||
@foreach (var item in Model.Items)
|
||||
@@ -2195,9 +2195,9 @@
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Resolution</label>
|
||||
<select class="form-select" id="rwResolution">
|
||||
<option value="">� Pending �</option>
|
||||
<option value="0">Recoated � No Charge</option>
|
||||
<option value="1">Recoated � Billed to Customer</option>
|
||||
<option value="">– Pending –</option>
|
||||
<option value="0">Recoated — No Charge</option>
|
||||
<option value="1">Recoated — Billed to Customer</option>
|
||||
<option value="2">Customer Credited</option>
|
||||
<option value="3">Written Off</option>
|
||||
<option value="4">No Action Required</option>
|
||||
@@ -2256,7 +2256,7 @@
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Worker <span class="text-danger">*</span></label>
|
||||
<select class="form-select" id="teWorkerId">
|
||||
<option value="">� Select worker �</option>
|
||||
<option value="">– Select worker –</option>
|
||||
@foreach (var w in (ViewBag.ShopWorkers as IEnumerable<dynamic> ?? []))
|
||||
{
|
||||
<option value="@w.Id">@w.Name</option>
|
||||
@@ -2275,7 +2275,7 @@
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Stage / Task</label>
|
||||
<input type="text" class="form-control" id="teStage" placeholder="e.g. Sandblasting, Coating, Masking�" list="stageOptions" />
|
||||
<input type="text" class="form-control" id="teStage" placeholder="e.g. Sandblasting, Coating, Masking…" list="stageOptions" />
|
||||
<datalist id="stageOptions">
|
||||
<option value="Sandblasting"></option>
|
||||
<option value="Masking & Taping"></option>
|
||||
@@ -2290,7 +2290,7 @@
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Notes</label>
|
||||
<textarea class="form-control" id="teNotes" rows="2" placeholder="Optional notes�"></textarea>
|
||||
<textarea class="form-control" id="teNotes" rows="2" placeholder="Optional notes…"></textarea>
|
||||
</div>
|
||||
<div class="text-danger small d-none" id="teError"></div>
|
||||
</div>
|
||||
@@ -2332,7 +2332,7 @@
|
||||
<script src="~/js/job-photos.js" asp-append-version="true"></script>
|
||||
<script src="~/js/customer-change.js" asp-append-version="true"></script>
|
||||
<script>
|
||||
// ── Inline date editing ──────────────────────────────────────────────
|
||||
// -- Inline date editing ----------------------------------------------
|
||||
const jobId = @Model.Id;
|
||||
const antiForgeryToken = () => document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
|
||||
@@ -2433,7 +2433,7 @@
|
||||
jobPhotoModule.init(@Model.Id, @Html.Raw(ViewBag.PhotoTagSuggestions ?? "[]"));
|
||||
|
||||
|
||||
// ── Auto-submit after wizard saves an item ────────────────────────
|
||||
// -- Auto-submit after wizard saves an item ------------------------
|
||||
let itemsModified = false;
|
||||
|
||||
// Wrap wizardSave to set a flag before the modal hides
|
||||
@@ -2451,12 +2451,12 @@
|
||||
}
|
||||
});
|
||||
|
||||
// ── Delete confirmation modal ─────────────────────────────────────
|
||||
// -- Delete confirmation modal -------------------------------------
|
||||
let pendingDeleteItemId = -1;
|
||||
const deleteModal = new bootstrap.Modal(document.getElementById('deleteConfirmModal'));
|
||||
const deleteItemToken = document.querySelector('input[name="__RequestVerificationToken"]').value;
|
||||
|
||||
// Delegated listener � handles all delete buttons via data attributes
|
||||
// Delegated listener -- handles all delete buttons via data attributes
|
||||
document.addEventListener('click', function (e) {
|
||||
const btn = e.target.closest('[data-delete-id]');
|
||||
if (!btn) return;
|
||||
@@ -2489,7 +2489,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- ── Rework / Warranty ────────────────────────────────────────────── -->
|
||||
<!-- -- Rework / Warranty ---------------------------------------------- -->
|
||||
<script>
|
||||
const rework = (() => {
|
||||
const jid = @Model.Id;
|
||||
@@ -2534,12 +2534,12 @@
|
||||
</div>
|
||||
<div class="small mt-1 text-muted">${r.defectDescription}</div>
|
||||
<div class="small text-muted mt-1">
|
||||
Found: ${r.discoveredByDisplay} � ${new Date(r.discoveredDate).toLocaleDateString()}
|
||||
${r.reportedByName ? '� ' + r.reportedByName : ''}
|
||||
Found: ${r.discoveredByDisplay} — ${new Date(r.discoveredDate).toLocaleDateString()}
|
||||
${r.reportedByName ? '– ' + r.reportedByName : ''}
|
||||
${r.jobItemDescription ? ' | Item: ' + r.jobItemDescription : ''}
|
||||
</div>
|
||||
${r.reworkJobNumber ? `<div class="small mt-1"><i class="bi bi-briefcase me-1"></i>Rework Job: <a href="/Jobs/Details/${r.reworkJobId}" class="text-decoration-none fw-semibold">${r.reworkJobNumber}</a></div>` : ''}
|
||||
${r.resolutionDisplay ? `<div class="small text-success mt-1"><i class="bi bi-check-circle me-1"></i>${r.resolutionDisplay}${r.actualReworkCost > 0 ? ' � $' + r.actualReworkCost.toFixed(2) : ''}</div>` : ''}
|
||||
${r.resolutionDisplay ? `<div class="small text-success mt-1"><i class="bi bi-check-circle me-1"></i>${r.resolutionDisplay}${r.actualReworkCost > 0 ? ' — $' + r.actualReworkCost.toFixed(2) : ''}</div>` : ''}
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
@@ -2645,7 +2645,7 @@
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- ── Job Costing ──────────────────────────────────────────────────── -->
|
||||
<!-- -- Job Costing ---------------------------------------------------- -->
|
||||
<script>
|
||||
const costing = (() => {
|
||||
const jid = @Model.Id;
|
||||
@@ -2685,7 +2685,7 @@
|
||||
document.getElementById('costingReworkBilled').textContent = fmt(d.reworkBilledToCustomer);
|
||||
const rBody = document.getElementById('reworkCostLines');
|
||||
rBody.innerHTML = d.reworkLines.map(l => `<tr>
|
||||
<td class="text-muted">${l.jobNumber ? `<a href="/Jobs/Details" class="text-decoration-none">${l.jobNumber}</a>` : 'No job'} � ${l.reason}${l.isEstimate ? ' <span class="badge bg-secondary" style="font-size:0.65rem;">est.</span>' : ''}</td>
|
||||
<td class="text-muted">${l.jobNumber ? `<a href="/Jobs/Details" class="text-decoration-none">${l.jobNumber}</a>` : 'No job'} – ${l.reason}${l.isEstimate ? ' <span class="badge bg-secondary" style="font-size:0.65rem;">est.</span>' : ''}</td>
|
||||
<td class="text-end text-nowrap">${l.billedToCustomer > 0 ? `<span class="text-success">${fmt(l.billedToCustomer)} billed</span>` : 'absorbed'}</td>
|
||||
<td class="text-end text-nowrap fw-semibold">${fmt(l.cost)}</td></tr>`).join('');
|
||||
} else {
|
||||
@@ -2701,14 +2701,14 @@
|
||||
|
||||
document.getElementById('costingMargin').textContent = `${d.grossMargin}%`;
|
||||
document.getElementById('costingQuotedMargin').textContent =
|
||||
d.quotedPrice > 0 ? `${d.quotedMargin}% (quoted ${fmt(d.quotedPrice)})` : '�';
|
||||
d.quotedPrice > 0 ? `${d.quotedMargin}% (quoted ${fmt(d.quotedPrice)})` : '—';
|
||||
|
||||
// Powder detail lines
|
||||
const pBody = document.getElementById('powderLines');
|
||||
pBody.innerHTML = d.hasPowderData
|
||||
? d.powderLines.map(l => `<tr>
|
||||
<td class="text-muted" style="max-width:160px;white-space:normal;">${l.description}${l.isActual ? ' <span class="badge bg-success" style="font-size:0.65rem;">actual</span>' : ''}</td>
|
||||
<td class="text-end text-nowrap">${l.lbs} lbs � ${fmt(l.costPerLb)}/lb</td>
|
||||
<td class="text-end text-nowrap">${l.lbs} lbs × ${fmt(l.costPerLb)}/lb</td>
|
||||
<td class="text-end text-nowrap fw-semibold">${fmt(l.total)}</td></tr>`).join('')
|
||||
: '<tr><td colspan="3" class="text-muted">No powder cost data on coats.</td></tr>';
|
||||
|
||||
@@ -2716,14 +2716,14 @@
|
||||
const lBody = document.getElementById('laborLines');
|
||||
lBody.innerHTML = d.hasLaborData
|
||||
? d.laborLines.map(l => `<tr>
|
||||
<td class="text-muted">${l.worker}${l.stage ? ' � ' + l.stage : ''}<br/><small>${l.workDate}</small></td>
|
||||
<td class="text-end text-nowrap">${l.hours}h � ${fmt(l.rate)}/hr${l.usingFallback ? ' <span title="Using standard labor rate" class="text-muted">*</span>' : ''}</td>
|
||||
<td class="text-muted">${l.worker}${l.stage ? ' – ' + l.stage : ''}<br/><small>${l.workDate}</small></td>
|
||||
<td class="text-end text-nowrap">${l.hours}h × ${fmt(l.rate)}/hr${l.usingFallback ? ' <span title="Using standard labor rate" class="text-muted">*</span>' : ''}</td>
|
||||
<td class="text-end text-nowrap fw-semibold">${fmt(l.total)}</td></tr>`).join('')
|
||||
: '<tr><td colspan="3" class="text-muted">No time entries logged yet.</td></tr>';
|
||||
|
||||
// Notes
|
||||
const notes = [];
|
||||
if (!d.hasPowderData && d.hasPowderRateButNoQty) notes.push('? Surface area not set on one or more items � edit the item and enter a surface area to calculate powder cost.');
|
||||
if (!d.hasPowderData && d.hasPowderRateButNoQty) notes.push('? Surface area not set on one or more items — edit the item and enter a surface area to calculate powder cost.');
|
||||
else if (!d.hasPowderData) notes.push('? Add powder cost per lb on coat records to include material cost.');
|
||||
if (!d.hasLaborData) notes.push('? Log time entries to include labor cost.');
|
||||
if (d.laborLines?.some(l => l.usingFallback)) notes.push('* One or more workers using standard labor rate fallback.');
|
||||
@@ -2754,7 +2754,7 @@
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- ── Time Tracking ─────────────────────────────────────────────────── -->
|
||||
<!-- -- Time Tracking --------------------------------------------------- -->
|
||||
<script>
|
||||
const timeTracking = (() => {
|
||||
const jid = @Model.Id;
|
||||
@@ -2762,7 +2762,7 @@
|
||||
const modal = new bootstrap.Modal(document.getElementById('timeEntryModal'));
|
||||
let entries = [];
|
||||
|
||||
// ── Load ──────────────────────────────────────────────────────────
|
||||
// -- Load ----------------------------------------------------------
|
||||
async function load() {
|
||||
const r = await fetch(`/Jobs/GetTimeEntries?jobId=${jid}`);
|
||||
entries = await r.json();
|
||||
@@ -2793,7 +2793,7 @@
|
||||
<td class="fw-semibold">${esc(e.workerName)}</td>
|
||||
<td class="small">${d}</td>
|
||||
<td class="text-end fw-semibold">${e.hoursWorked.toFixed(2)}</td>
|
||||
<td class="small">${e.stage ? `<span class="badge bg-secondary-subtle text-secondary">${esc(e.stage)}</span>` : '<span class="text-muted">�</span>'}</td>
|
||||
<td class="small">${e.stage ? `<span class="badge bg-secondary-subtle text-secondary">${esc(e.stage)}</span>` : '<span class="text-muted">—</span>'}</td>
|
||||
<td class="small text-muted">${esc(e.notes ?? '')}</td>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-xs btn-outline-secondary me-1 py-0 px-1" title="Edit" onclick="timeTracking.openEdit(${e.id})"><i class="bi bi-pencil"></i></button>
|
||||
@@ -2805,12 +2805,12 @@
|
||||
}
|
||||
|
||||
function updateTotals(total) {
|
||||
const fmt = total > 0 ? total.toFixed(2) + ' hrs' : '�';
|
||||
const fmt = total > 0 ? total.toFixed(2) + ' hrs' : '—';
|
||||
document.getElementById('totalHoursDisplay').textContent = fmt;
|
||||
document.getElementById('timeEntriesTotalHours').textContent = total > 0 ? total.toFixed(2) : '�';
|
||||
document.getElementById('timeEntriesTotalHours').textContent = total > 0 ? total.toFixed(2) : '—';
|
||||
}
|
||||
|
||||
// ── Modal helpers ─────────────────────────────────────────────────
|
||||
// -- Modal helpers -------------------------------------------------
|
||||
function openAdd() {
|
||||
document.getElementById('timeEntryModalTitle').textContent = 'Log Time';
|
||||
document.getElementById('teEntryId').value = '0';
|
||||
@@ -2917,7 +2917,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
// ── Deposits ─────────────────────────────────────────────────────────────
|
||||
// -- Deposits -------------------------------------------------------------
|
||||
// Note: antiForgeryToken() is already defined above in this script block
|
||||
document.getElementById('addDepositForm')?.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
@@ -2931,7 +2931,7 @@
|
||||
}
|
||||
|
||||
if (errEl) errEl.classList.add('d-none');
|
||||
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving�'; }
|
||||
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving…'; }
|
||||
|
||||
const params = new URLSearchParams(new FormData(form));
|
||||
|
||||
@@ -2973,7 +2973,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Collapsible sections ──────────────────────────────────────────────────
|
||||
// -- Collapsible sections --------------------------------------------------
|
||||
(function () {
|
||||
const storageKey = 'jobDetailCollapse_@Model.Id';
|
||||
const sections = ['collapseTimeTracking', 'collapsePartIntake', 'collapsePhotos', 'collapseDeposits', 'collapseMaterials'];
|
||||
@@ -3012,7 +3012,7 @@
|
||||
});
|
||||
})();
|
||||
|
||||
// ── Part Intake Modal ─────────────────────────────────────────────────────
|
||||
// -- Part Intake Modal --------------------------------------------------
|
||||
(function () {
|
||||
const expectedCount = @intakeExpectedCount;
|
||||
const partCountInput = document.getElementById('intakePartCount');
|
||||
@@ -3105,7 +3105,7 @@
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Template Name <span class="text-danger">*</span></label>
|
||||
<input type="text" name="templateName" class="form-control" required maxlength="100"
|
||||
placeholder="e.g. Wheel Refinish � Standard 4pc">
|
||||
placeholder="e.g. Wheel Refinish — Standard 4pc">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
if (taxField) {
|
||||
taxField.value = exemptIds.has(customerId) ? 0 : (meta.companyTaxPercent ?? meta.taxPercent);
|
||||
}
|
||||
// Recalculate the live preview so it reflects the updated tax rate immediately
|
||||
if (typeof scheduleAutoPricing === 'function') scheduleAutoPricing();
|
||||
|
||||
const noEmail = customerId > 0 && noEmailIds.has(customerId);
|
||||
const emailSection = document.getElementById('emailNotifySection');
|
||||
|
||||
@@ -1067,8 +1067,7 @@
|
||||
var hasEquipment = User.HasClaim("Permission", "ManageEquipment") || User.IsInRole("SuperAdmin");
|
||||
var hasMaintenance = User.HasClaim("Permission", "ManageMaintenance") || User.IsInRole("SuperAdmin");
|
||||
var hasFinance = _isAdminOrManager || User.HasClaim("Permission", "ManageFinance");
|
||||
var hasShopWorkers = _isAdminOrManager || User.HasClaim("Permission", "ManageShopWorkers");
|
||||
var hasReports = _isAdminOrManager || User.HasClaim("Permission", "ViewReports");
|
||||
var hasReports = _isAdminOrManager || User.HasClaim("Permission", "ViewReports");
|
||||
var showOperations = hasCustomers || hasQuotes || hasInvoices || hasJobs || hasCalendar;
|
||||
var showInventorySection = hasInventory || hasVendors;
|
||||
var showEquipmentSection = hasEquipment || hasMaintenance;
|
||||
|
||||
@@ -12,8 +12,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
setupCsvImportForm('csvImportMaintenanceForm', 'csvMaintenanceFile', 'csvImportMaintenanceBtn', '/Tools/CsvImportMaintenance', 'csvMaintenanceResults');
|
||||
setupCsvImportForm('csvImportSettingsForm', 'csvSettingsFile', 'csvImportSettingsBtn', '/Tools/CsvImportCompanySettings', 'csvSettingsResults');
|
||||
setupCsvImportForm('csvImportVendorsForm', 'csvVendorsFile', 'csvImportVendorsBtn', '/Tools/CsvImportVendors', 'csvVendorsResults');
|
||||
setupCsvImportForm('csvImportShopWorkersForm', 'csvShopWorkersFile', 'csvImportShopWorkersBtn', '/Tools/CsvImportShopWorkers', 'csvShopWorkersResults');
|
||||
setupCsvImportForm('csvImportPrepServicesForm', 'csvPrepServicesFile', 'csvImportPrepServicesBtn', '/Tools/CsvImportPrepServices', 'csvPrepServicesResults');
|
||||
setupCsvImportForm('csvImportPrepServicesForm', 'csvPrepServicesFile', 'csvImportPrepServicesBtn', '/Tools/CsvImportPrepServices', 'csvPrepServicesResults');
|
||||
});
|
||||
|
||||
function setupCsvImportForm(formId, fileInputId, submitBtnId, actionUrl, resultsId) {
|
||||
|
||||
@@ -2904,7 +2904,8 @@ async function runAutoPricing() {
|
||||
try {
|
||||
// Collect current form meta
|
||||
const customerId = parseInt(document.querySelector('[name="CustomerId"]')?.value) || null;
|
||||
const taxPercent = parseFloat(document.querySelector('[name="TaxPercent"]')?.value) || pageMeta.taxPercent || 0;
|
||||
const _taxField = document.querySelector('[name="TaxPercent"]');
|
||||
const taxPercent = _taxField ? parseFloat(_taxField.value) : (pageMeta.taxPercent ?? 0);
|
||||
const discountType = document.getElementById('discountTypeSelect')?.value || 'None';
|
||||
const discountVal = parseFloat(document.getElementById('discountValueInput')?.value) || 0;
|
||||
const isRushJob = document.getElementById('IsRushJob')?.checked || false;
|
||||
|
||||
@@ -59,7 +59,8 @@ public class JobItemAssemblyServiceTests
|
||||
var pricing = new QuoteItemPricingResult
|
||||
{
|
||||
UnitPrice = 29.99m,
|
||||
TotalPrice = 59.98m
|
||||
TotalPrice = 59.98m,
|
||||
LaborCost = 23.992m // explicitly from pricing engine, not a 0.4× multiplier
|
||||
};
|
||||
|
||||
var jobItem = _service.CreateJobItem(source, jobId: 10, companyId: 3, pricing: pricing, createdAtUtc: CreatedAtUtc);
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using PowderCoating.Application.DTOs.Invoice;
|
||||
using PowderCoating.Application.DTOs.Quote;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Application.Services;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Infrastructure.Repositories;
|
||||
using PowderCoating.Web.Controllers;
|
||||
|
||||
namespace PowderCoating.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that quantities, prices, overrides, and charges move correctly through all three
|
||||
/// pricing stages: Quote → Job → Invoice. Each test targets one transition or cross-cutting concern.
|
||||
/// </summary>
|
||||
public class PricingStageFlowTests
|
||||
{
|
||||
// ─── Stage 1: QuotePricingAssemblyService.ApplyPricingSnapshot ───────────────
|
||||
|
||||
[Fact]
|
||||
public void ApplyPricingSnapshot_StoresAllNewBreakdownFields()
|
||||
{
|
||||
// FacilityOverheadCost, FacilityOverheadRatePerHour, PricingTierDiscount, QuoteDiscount,
|
||||
// and SubtotalAfterDiscount were added in a recent migration. Verify they are all stored.
|
||||
var service = CreateAssemblyService(CreateContext());
|
||||
var quote = new Quote();
|
||||
var pricing = new QuotePricingResult
|
||||
{
|
||||
FacilityOverheadCost = 12.50m,
|
||||
FacilityOverheadRatePerHour = 25m,
|
||||
PricingTierDiscountAmount = 5m,
|
||||
PricingTierDiscountPercent = 2m,
|
||||
QuoteDiscountAmount = 10m,
|
||||
QuoteDiscountPercent = 4m,
|
||||
DiscountAmount = 15m,
|
||||
DiscountPercent = 6m,
|
||||
SubtotalAfterDiscount = 235m,
|
||||
RushFee = 20m,
|
||||
TaxAmount = 23.5m,
|
||||
Total = 278.50m,
|
||||
SubtotalBeforeDiscount = 250m,
|
||||
ItemsSubtotal = 200m,
|
||||
OvenBatchCost = 18m,
|
||||
ShopSuppliesAmount = 8m,
|
||||
ShopSuppliesPercent = 4m
|
||||
};
|
||||
|
||||
service.ApplyPricingSnapshot(quote, pricing);
|
||||
|
||||
Assert.Equal(12.50m, quote.FacilityOverheadCost, precision: 2);
|
||||
Assert.Equal(25m, quote.FacilityOverheadRatePerHour, precision: 2);
|
||||
Assert.Equal(5m, quote.PricingTierDiscountAmount, precision: 2);
|
||||
Assert.Equal(2m, quote.PricingTierDiscountPercent, precision: 2);
|
||||
Assert.Equal(10m, quote.QuoteDiscountAmount, precision: 2);
|
||||
Assert.Equal(4m, quote.QuoteDiscountPercent, precision: 2);
|
||||
Assert.Equal(15m, quote.DiscountAmount, precision: 2);
|
||||
Assert.Equal(6m, quote.DiscountPercent, precision: 2);
|
||||
Assert.Equal(235m, quote.SubtotalAfterDiscount, precision: 2);
|
||||
Assert.Equal(20m, quote.RushFee, precision: 2);
|
||||
Assert.Equal(23.5m, quote.TaxAmount, precision: 2);
|
||||
Assert.Equal(278.50m, quote.Total, precision: 2);
|
||||
}
|
||||
|
||||
// ─── Stage 2: Quote → Job (QuotesController.UpdateQuoteStatus) ────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task QuoteToJob_PricingSnapshotCarriesAllCharges()
|
||||
{
|
||||
// Verifies that OvenBatchCost, FacilityOverheadCost, ShopSuppliesAmount, RushFee,
|
||||
// and all discount fields from the approved quote land in Job.PricingBreakdownJson.
|
||||
await using var context = CreateContext();
|
||||
SeedQuoteWithFullPricing(context);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var controller = CreateQuotesController(context);
|
||||
|
||||
var approvedStatusId = context.QuoteStatusLookups.Single(s => s.StatusCode == "APPROVED").Id;
|
||||
var result = await controller.UpdateQuoteStatus(new UpdateQuoteStatusRequest
|
||||
{
|
||||
QuoteId = 1,
|
||||
StatusId = approvedStatusId
|
||||
});
|
||||
|
||||
Assert.IsType<JsonResult>(result);
|
||||
|
||||
var job = await context.Jobs.SingleAsync();
|
||||
Assert.NotNull(job.PricingBreakdownJson);
|
||||
|
||||
var breakdown = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson!);
|
||||
Assert.NotNull(breakdown);
|
||||
|
||||
Assert.Equal(150m, breakdown.ItemsSubtotal, precision: 2);
|
||||
Assert.Equal(18m, breakdown.OvenBatchCost, precision: 2);
|
||||
Assert.Equal(12m, breakdown.FacilityOverheadCost, precision: 2);
|
||||
Assert.Equal(6m, breakdown.ShopSuppliesAmount, precision: 2);
|
||||
Assert.Equal(25m, breakdown.RushFee, precision: 2);
|
||||
Assert.Equal(15m, breakdown.DiscountAmount, precision: 2);
|
||||
Assert.Equal(211m, breakdown.Total, precision: 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QuoteToJob_ItemPricesAndOverridesTransfer()
|
||||
{
|
||||
// Verifies that UnitPrice, TotalPrice, ManualUnitPrice, PowderCostOverride,
|
||||
// CatalogItemId, and Notes all survive the quote→job item conversion.
|
||||
await using var context = CreateContext();
|
||||
SeedQuoteWithFullPricing(context);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var controller = CreateQuotesController(context);
|
||||
var approvedStatusId = context.QuoteStatusLookups.Single(s => s.StatusCode == "APPROVED").Id;
|
||||
await controller.UpdateQuoteStatus(new UpdateQuoteStatusRequest { QuoteId = 1, StatusId = approvedStatusId });
|
||||
|
||||
var jobItem = await context.JobItems.SingleAsync();
|
||||
Assert.Equal(75m, jobItem.UnitPrice, precision: 2);
|
||||
Assert.Equal(150m, jobItem.TotalPrice, precision: 2);
|
||||
Assert.Equal(69m, jobItem.ManualUnitPrice);
|
||||
Assert.Equal(8.50m, jobItem.PowderCostOverride);
|
||||
Assert.Equal(99, jobItem.CatalogItemId);
|
||||
Assert.Equal("Handle carefully — thin walls", jobItem.Notes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QuoteToJob_CoatInventoryIdAndPowderToOrderTransfer()
|
||||
{
|
||||
// InventoryItemId on coats gates the powder charging logic in PricingCalculationService.
|
||||
// PowderToOrder is the purchase quantity — both must survive quote→job conversion.
|
||||
await using var context = CreateContext();
|
||||
SeedQuoteWithFullPricing(context);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var controller = CreateQuotesController(context);
|
||||
var approvedStatusId = context.QuoteStatusLookups.Single(s => s.StatusCode == "APPROVED").Id;
|
||||
await controller.UpdateQuoteStatus(new UpdateQuoteStatusRequest { QuoteId = 1, StatusId = approvedStatusId });
|
||||
|
||||
var coat = await context.JobItemCoats.SingleAsync();
|
||||
Assert.Equal(50, coat.InventoryItemId);
|
||||
Assert.Equal(2.0m, coat.PowderToOrder);
|
||||
Assert.Equal(4.50m, coat.PowderCostPerLb);
|
||||
}
|
||||
|
||||
// ─── Stage 3: Job → Invoice (InvoicesController.Create GET with jobId) ──────────
|
||||
|
||||
[Fact]
|
||||
public async Task JobToInvoice_ItemFieldsPopulateCorrectly()
|
||||
{
|
||||
// Notes and CatalogItemId on JobItem must reach InvoiceItem.
|
||||
await using var context = CreateContext();
|
||||
SeedJobForInvoicing(context, hasSourceQuote: false);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var controller = CreateInvoicesController(context);
|
||||
var result = await controller.Create(jobId: 1) as ViewResult;
|
||||
Assert.NotNull(result);
|
||||
|
||||
var dto = Assert.IsType<CreateInvoiceDto>(result.Model);
|
||||
var item = dto.InvoiceItems.First(i => i.SourceJobItemId.HasValue);
|
||||
|
||||
Assert.Equal(3m, item.Quantity);
|
||||
Assert.Equal(45m, item.UnitPrice, precision: 2);
|
||||
Assert.Equal(135m, item.TotalPrice, precision: 2);
|
||||
Assert.Equal("Gloss Black", item.ColorName);
|
||||
Assert.Equal(99, item.CatalogItemId);
|
||||
Assert.Equal("Watch corners — mask before blasting", item.Notes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task JobToInvoice_DirectJob_AddsOvenShopSuppliesRushFeeLines()
|
||||
{
|
||||
// A job created directly (no source quote) must invoice all three processing charges
|
||||
// separately, reading RushFee and FacilityOverheadCost from PricingBreakdownJson.
|
||||
await using var context = CreateContext();
|
||||
SeedJobForInvoicing(context, hasSourceQuote: false);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var controller = CreateInvoicesController(context);
|
||||
var result = await controller.Create(jobId: 1) as ViewResult;
|
||||
Assert.NotNull(result);
|
||||
|
||||
var dto = Assert.IsType<CreateInvoiceDto>(result.Model);
|
||||
var descriptions = dto.InvoiceItems.Select(i => i.Description).ToList();
|
||||
|
||||
Assert.Contains("Oven Processing Fee", descriptions);
|
||||
Assert.Contains("Facility Overhead", descriptions);
|
||||
Assert.Contains("Shop Supplies (4%)", descriptions);
|
||||
Assert.Contains("Rush Fee", descriptions);
|
||||
|
||||
var oven = dto.InvoiceItems.Single(i => i.Description == "Oven Processing Fee");
|
||||
var overhead = dto.InvoiceItems.Single(i => i.Description == "Facility Overhead");
|
||||
var shop = dto.InvoiceItems.Single(i => i.Description == "Shop Supplies (4%)");
|
||||
var rush = dto.InvoiceItems.Single(i => i.Description == "Rush Fee");
|
||||
|
||||
Assert.Equal(18m, oven.TotalPrice, precision: 2);
|
||||
Assert.Equal(12m, overhead.TotalPrice, precision: 2);
|
||||
Assert.Equal(6m, shop.TotalPrice, precision: 2);
|
||||
Assert.Equal(25m, rush.TotalPrice, precision: 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task JobToInvoice_FromQuote_BundlesAllProcessingFeesIncludingFacilityOverhead()
|
||||
{
|
||||
// When a job came from a quote, all processing charges must be bundled as one line,
|
||||
// including FacilityOverheadCost which was previously missing.
|
||||
await using var context = CreateContext();
|
||||
SeedJobForInvoicing(context, hasSourceQuote: true);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var controller = CreateInvoicesController(context);
|
||||
var result = await controller.Create(jobId: 1) as ViewResult;
|
||||
Assert.NotNull(result);
|
||||
|
||||
var dto = Assert.IsType<CreateInvoiceDto>(result.Model);
|
||||
var processingLine = dto.InvoiceItems.SingleOrDefault(i => i.Description == "Oven & Shop Processing Fees");
|
||||
Assert.NotNull(processingLine);
|
||||
|
||||
// OvenBatchCost(18) + FacilityOverheadCost(12) + ShopSuppliesAmount(6) + RushFee(25) = 61
|
||||
Assert.Equal(61m, processingLine!.TotalPrice, precision: 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task JobToInvoice_TaxAndDiscountFromQuoteNotRecomputed()
|
||||
{
|
||||
// Invoice must carry the agreed quote TaxPercent and DiscountAmount,
|
||||
// not re-derive from current company defaults.
|
||||
await using var context = CreateContext();
|
||||
SeedJobForInvoicing(context, hasSourceQuote: true);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var controller = CreateInvoicesController(context);
|
||||
var result = await controller.Create(jobId: 1) as ViewResult;
|
||||
Assert.NotNull(result);
|
||||
|
||||
var dto = Assert.IsType<CreateInvoiceDto>(result.Model);
|
||||
Assert.Equal(8.5m, dto.TaxPercent, precision: 2);
|
||||
Assert.Equal(15m, dto.DiscountAmount, precision: 2);
|
||||
}
|
||||
|
||||
// ─── JobItemAssemblyService: Notes field ──────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void CreateJobItem_FromDto_PreservesNotes()
|
||||
{
|
||||
var svc = new JobItemAssemblyService();
|
||||
var dto = new CreateQuoteItemDto { Description = "Part", Notes = "Fragile — no drop" };
|
||||
var pricing = new QuoteItemPricingResult { UnitPrice = 10m, TotalPrice = 10m };
|
||||
|
||||
var item = svc.CreateJobItem(dto, jobId: 1, companyId: 1, pricing, DateTime.UtcNow);
|
||||
|
||||
Assert.Equal("Fragile — no drop", item.Notes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateJobItem_FromQuoteItem_PreservesNotes()
|
||||
{
|
||||
var svc = new JobItemAssemblyService();
|
||||
var quoteItem = new QuoteItem { Description = "Part", Notes = "Do not sandblast" };
|
||||
|
||||
var item = svc.CreateJobItem(quoteItem, jobId: 1, companyId: 1, DateTime.UtcNow);
|
||||
|
||||
Assert.Equal("Do not sandblast", item.Notes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateJobItem_FromJobItem_PreservesNotes()
|
||||
{
|
||||
var svc = new JobItemAssemblyService();
|
||||
var source = new JobItem { Description = "Part", Notes = "Carry-over note", LaborCost = 0m };
|
||||
|
||||
var item = svc.CreateJobItem(source, jobId: 2, companyId: 1, DateTime.UtcNow);
|
||||
|
||||
Assert.Equal("Carry-over note", item.Notes);
|
||||
}
|
||||
|
||||
// ─── LaborCost: must come from pricing engine, not a hardcoded multiplier ─────
|
||||
|
||||
[Fact]
|
||||
public void CreateJobItem_FromDto_UsesLaborCostFromPricingResult()
|
||||
{
|
||||
var svc = new JobItemAssemblyService();
|
||||
var dto = new CreateQuoteItemDto { Description = "Rail" };
|
||||
var pricing = new QuoteItemPricingResult { UnitPrice = 100m, TotalPrice = 200m, LaborCost = 55m };
|
||||
|
||||
var item = svc.CreateJobItem(dto, jobId: 1, companyId: 1, pricing, DateTime.UtcNow);
|
||||
|
||||
Assert.Equal(55m, item.LaborCost, precision: 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateJobItem_FromQuoteItem_UsesStoredItemLaborCost()
|
||||
{
|
||||
var svc = new JobItemAssemblyService();
|
||||
var quoteItem = new QuoteItem
|
||||
{
|
||||
Description = "Rail",
|
||||
UnitPrice = 100m,
|
||||
TotalPrice = 200m,
|
||||
ItemLaborCost = 62m
|
||||
};
|
||||
|
||||
var item = svc.CreateJobItem(quoteItem, jobId: 1, companyId: 1, DateTime.UtcNow);
|
||||
|
||||
Assert.Equal(62m, item.LaborCost, precision: 2);
|
||||
}
|
||||
|
||||
// ─── Seed helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static void SeedQuoteWithFullPricing(ApplicationDbContext context)
|
||||
{
|
||||
context.Customers.Add(new Customer { Id = 1, CompanyId = 1, CompanyName = "Test Co" });
|
||||
context.InventoryItems.Add(new InventoryItem
|
||||
{
|
||||
Id = 50, CompanyId = 1, SKU = "BLK-1", Name = "Gloss Black",
|
||||
ColorCode = "RAL9005", Finish = "Gloss", Category = "Powder", UnitOfMeasure = "lbs"
|
||||
});
|
||||
|
||||
context.QuoteStatusLookups.AddRange(
|
||||
new QuoteStatusLookup { Id = 1, CompanyId = 1, StatusCode = "DRAFT", DisplayName = "Draft" },
|
||||
new QuoteStatusLookup { Id = 2, CompanyId = 1, StatusCode = "APPROVED", DisplayName = "Approved" },
|
||||
new QuoteStatusLookup { Id = 3, CompanyId = 1, StatusCode = "CONVERTED", DisplayName = "Converted" });
|
||||
|
||||
context.JobStatusLookups.Add(new JobStatusLookup
|
||||
{ Id = 10, CompanyId = 1, StatusCode = "APPROVED", DisplayName = "Approved" });
|
||||
context.JobPriorityLookups.AddRange(
|
||||
new JobPriorityLookup { Id = 20, CompanyId = 1, PriorityCode = "NORMAL", DisplayName = "Normal" },
|
||||
new JobPriorityLookup { Id = 21, CompanyId = 1, PriorityCode = "RUSH", DisplayName = "Rush" });
|
||||
context.PrepServices.Add(new PrepService
|
||||
{ Id = 5, CompanyId = 1, ServiceName = "Sandblast", DisplayOrder = 1, IsActive = true });
|
||||
|
||||
context.Quotes.Add(new Quote
|
||||
{
|
||||
Id = 1, CompanyId = 1, QuoteNumber = "Q-2601-0001", CustomerId = 1, QuoteStatusId = 1,
|
||||
IsRushJob = true,
|
||||
ItemsSubtotal = 150m,
|
||||
OvenBatchCost = 18m,
|
||||
FacilityOverheadCost = 12m,
|
||||
ShopSuppliesAmount = 6m,
|
||||
ShopSuppliesPercent = 4m,
|
||||
RushFee = 25m,
|
||||
DiscountAmount = 15m,
|
||||
DiscountPercent = 6m,
|
||||
SubtotalAfterDiscount = 196m,
|
||||
TaxPercent = 8.5m,
|
||||
TaxAmount = 16.66m,
|
||||
Total = 211m
|
||||
});
|
||||
|
||||
context.QuoteItems.Add(new QuoteItem
|
||||
{
|
||||
Id = 100, QuoteId = 1, CompanyId = 1,
|
||||
Description = "Powder coat rail",
|
||||
Quantity = 2m,
|
||||
SurfaceAreaSqFt = 20m,
|
||||
CatalogItemId = 99,
|
||||
IsSalesItem = false,
|
||||
ManualUnitPrice = 69m,
|
||||
PowderCostOverride = 8.50m,
|
||||
UnitPrice = 75m,
|
||||
TotalPrice = 150m,
|
||||
ItemLaborCost = 40m,
|
||||
Notes = "Handle carefully — thin walls",
|
||||
IncludePrepCost = true,
|
||||
EstimatedMinutes = 30
|
||||
});
|
||||
|
||||
context.QuoteItemCoats.Add(new QuoteItemCoat
|
||||
{
|
||||
Id = 101, QuoteItemId = 100, CompanyId = 1,
|
||||
CoatName = "Base Coat", Sequence = 1,
|
||||
InventoryItemId = 50,
|
||||
ColorName = "Old Name",
|
||||
CoverageSqFtPerLb = 30m,
|
||||
TransferEfficiency = 65m,
|
||||
PowderCostPerLb = 4.50m,
|
||||
PowderToOrder = 2.0m
|
||||
});
|
||||
|
||||
context.QuoteItemPrepServices.Add(new QuoteItemPrepService
|
||||
{ Id = 102, QuoteItemId = 100, CompanyId = 1, PrepServiceId = 5, EstimatedMinutes = 10 });
|
||||
}
|
||||
|
||||
private static void SeedJobForInvoicing(ApplicationDbContext context, bool hasSourceQuote)
|
||||
{
|
||||
context.Customers.Add(new Customer { Id = 1, CompanyId = 1, CompanyName = "Test Co" });
|
||||
|
||||
context.JobStatusLookups.Add(new JobStatusLookup
|
||||
{ Id = 1, CompanyId = 1, StatusCode = "COMPLETED", DisplayName = "Completed" });
|
||||
context.JobPriorityLookups.Add(new JobPriorityLookup
|
||||
{ Id = 1, CompanyId = 1, PriorityCode = "NORMAL", DisplayName = "Normal" });
|
||||
|
||||
// Serialized breakdown carrying FacilityOverheadCost and RushFee
|
||||
var breakdown = new QuotePricingBreakdownDto
|
||||
{
|
||||
ItemsSubtotal = 135m,
|
||||
OvenBatchCost = 18m,
|
||||
FacilityOverheadCost = 12m,
|
||||
ShopSuppliesAmount = 6m,
|
||||
ShopSuppliesPercent = 4m,
|
||||
RushFee = 25m,
|
||||
TaxPercent = 8.5m,
|
||||
Total = 211m
|
||||
};
|
||||
|
||||
Quote? quote = null;
|
||||
if (hasSourceQuote)
|
||||
{
|
||||
quote = new Quote
|
||||
{
|
||||
Id = 1, CompanyId = 1, QuoteNumber = "Q-TEST", CustomerId = 1,
|
||||
QuoteStatusId = 1,
|
||||
OvenBatchCost = 18m,
|
||||
FacilityOverheadCost = 12m,
|
||||
ShopSuppliesAmount = 6m,
|
||||
ShopSuppliesPercent = 4m,
|
||||
RushFee = 25m,
|
||||
DiscountAmount = 15m,
|
||||
TaxPercent = 8.5m,
|
||||
Total = 211m
|
||||
};
|
||||
context.QuoteStatusLookups.Add(new QuoteStatusLookup
|
||||
{ Id = 1, CompanyId = 1, StatusCode = "CONVERTED", DisplayName = "Converted" });
|
||||
context.Quotes.Add(quote);
|
||||
}
|
||||
|
||||
context.Jobs.Add(new Job
|
||||
{
|
||||
Id = 1, CompanyId = 1, JobNumber = "JOB-TEST", CustomerId = 1,
|
||||
Description = "Test job",
|
||||
JobStatusId = 1,
|
||||
JobPriorityId = 1,
|
||||
QuoteId = hasSourceQuote ? 1 : null,
|
||||
OvenBatchCost = 18m,
|
||||
ShopSuppliesAmount = 6m,
|
||||
ShopSuppliesPercent = 4m,
|
||||
IsRushJob = true,
|
||||
FinalPrice = 211m,
|
||||
PricingBreakdownJson = JsonSerializer.Serialize(breakdown)
|
||||
});
|
||||
|
||||
context.JobItems.Add(new JobItem
|
||||
{
|
||||
Id = 10, JobId = 1, CompanyId = 1,
|
||||
Description = "Powder coat wheel",
|
||||
Quantity = 3m,
|
||||
UnitPrice = 45m,
|
||||
TotalPrice = 135m,
|
||||
ColorName = "Gloss Black",
|
||||
CatalogItemId = 99,
|
||||
Notes = "Watch corners — mask before blasting",
|
||||
EstimatedMinutes = 20,
|
||||
LaborCost = 30m
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Controller / service factory helpers ────────────────────────────────────
|
||||
|
||||
private static QuotePricingAssemblyService CreateAssemblyService(ApplicationDbContext context) =>
|
||||
new(new UnitOfWork(context),
|
||||
Mock.Of<IPricingCalculationService>(),
|
||||
Mock.Of<IInventoryAiLookupService>(),
|
||||
Mock.Of<ILogger<QuotePricingAssemblyService>>());
|
||||
|
||||
private static QuotesController CreateQuotesController(ApplicationDbContext context)
|
||||
{
|
||||
var lookupCache = new Mock<ILookupCacheService>();
|
||||
lookupCache.Setup(x => x.GetQuoteStatusLookupsAsync(It.IsAny<int>()))
|
||||
.ReturnsAsync(() => context.QuoteStatusLookups.ToList());
|
||||
|
||||
return new QuotesController(
|
||||
new UnitOfWork(context),
|
||||
Mock.Of<AutoMapper.IMapper>(),
|
||||
Mock.Of<IPricingCalculationService>(),
|
||||
CreateUserManager().Object,
|
||||
Mock.Of<ILogger<QuotesController>>(),
|
||||
Mock.Of<IPdfService>(),
|
||||
CreateTenantContext().Object,
|
||||
Mock.Of<IMeasurementConversionService>(),
|
||||
lookupCache.Object,
|
||||
Mock.Of<INotificationService>(),
|
||||
Mock.Of<ISubscriptionService>(),
|
||||
new JobItemAssemblyService(),
|
||||
Mock.Of<IQuotePricingAssemblyService>(),
|
||||
new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(),
|
||||
Mock.Of<IPlatformSettingsService>(),
|
||||
Mock.Of<IQuotePhotoService>(),
|
||||
Mock.Of<IAiQuoteService>(),
|
||||
Mock.Of<IWebHostEnvironment>(),
|
||||
Mock.Of<IJobPhotoService>(),
|
||||
Mock.Of<IAiUsageLogger>(),
|
||||
Mock.Of<ICompanyLogoService>(),
|
||||
Mock.Of<IInventoryAiLookupService>());
|
||||
}
|
||||
|
||||
private static InvoicesController CreateInvoicesController(ApplicationDbContext context)
|
||||
{
|
||||
var controller = new InvoicesController(
|
||||
new UnitOfWork(context),
|
||||
Mock.Of<AutoMapper.IMapper>(),
|
||||
CreateUserManager().Object,
|
||||
Mock.Of<ILogger<InvoicesController>>(),
|
||||
Mock.Of<IPdfService>(),
|
||||
CreateTenantContext().Object,
|
||||
Mock.Of<INotificationService>(),
|
||||
Mock.Of<IAccountBalanceService>(),
|
||||
Mock.Of<ICompanyLogoService>());
|
||||
|
||||
var identity = new ClaimsIdentity([new Claim(ClaimTypes.Role, "SuperAdmin")], "Test");
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext { User = principal }
|
||||
};
|
||||
return controller;
|
||||
}
|
||||
|
||||
private static Mock<UserManager<ApplicationUser>> CreateUserManager()
|
||||
{
|
||||
var store = new Mock<IUserStore<ApplicationUser>>();
|
||||
var mgr = new Mock<UserManager<ApplicationUser>>(
|
||||
store.Object, null!, null!, null!, null!, null!, null!, null!, null!);
|
||||
mgr.Setup(m => m.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>()))
|
||||
.ReturnsAsync(new ApplicationUser
|
||||
{
|
||||
Id = "user-1",
|
||||
CompanyId = 1,
|
||||
UserName = "testuser",
|
||||
Email = "test@test.com"
|
||||
});
|
||||
return mgr;
|
||||
}
|
||||
|
||||
private static Mock<ITenantContext> CreateTenantContext()
|
||||
{
|
||||
var tc = new Mock<ITenantContext>();
|
||||
tc.Setup(x => x.GetCurrentCompanyId()).Returns(1);
|
||||
tc.Setup(x => x.IsSuperAdmin()).Returns(true);
|
||||
tc.Setup(x => x.IsPlatformAdmin()).Returns(true);
|
||||
return tc;
|
||||
}
|
||||
|
||||
private static ApplicationDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.ConfigureWarnings(x => x.Ignore(InMemoryEventId.TransactionIgnoredWarning))
|
||||
.Options;
|
||||
|
||||
var identity = new ClaimsIdentity([new Claim(ClaimTypes.Role, "SuperAdmin")], "Test");
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
byte[]? noBytes = null;
|
||||
var sessionMock = new Mock<ISession>();
|
||||
sessionMock.Setup(s => s.TryGetValue(It.IsAny<string>(), out noBytes)).Returns(false);
|
||||
|
||||
var httpContextMock = new Mock<HttpContext>();
|
||||
httpContextMock.SetupGet(c => c.User).Returns(principal);
|
||||
httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object);
|
||||
|
||||
var accessor = new Mock<IHttpContextAccessor>();
|
||||
accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object);
|
||||
|
||||
return new ApplicationDbContext(options, accessor.Object, null!);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user