Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 91a5dbe30c | |||
| b2a1b9a0be | |||
| 1a44133a63 |
@@ -112,6 +112,7 @@ namespace PowderCoating.Application.DTOs.Company
|
|||||||
|
|
||||||
// Labor Rates
|
// Labor Rates
|
||||||
public decimal StandardLaborRate { get; set; }
|
public decimal StandardLaborRate { get; set; }
|
||||||
|
public decimal? LaborCostPerHour { get; set; }
|
||||||
public decimal AdditionalCoatLaborPercent { get; set; }
|
public decimal AdditionalCoatLaborPercent { get; set; }
|
||||||
|
|
||||||
// Equipment Operating Costs
|
// Equipment Operating Costs
|
||||||
@@ -185,6 +186,10 @@ namespace PowderCoating.Application.DTOs.Company
|
|||||||
[Display(Name = "Standard Labor Rate ($/hr)")]
|
[Display(Name = "Standard Labor Rate ($/hr)")]
|
||||||
public decimal StandardLaborRate { get; set; }
|
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")]
|
[Range(0, 100, ErrorMessage = "Additional coat labor percent must be between 0 and 100")]
|
||||||
[Display(Name = "Additional Coat Labor (%)")]
|
[Display(Name = "Additional Coat Labor (%)")]
|
||||||
public decimal AdditionalCoatLaborPercent { get; set; } = 30m;
|
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; }
|
|
||||||
}
|
|
||||||
@@ -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")]
|
[Display(Name = "Active")]
|
||||||
public bool IsActive { get; set; }
|
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")]
|
[Required(ErrorMessage = "Hire date is required")]
|
||||||
[Display(Name = "Hire Date")]
|
[Display(Name = "Hire Date")]
|
||||||
public DateTime HireDate { get; set; }
|
public DateTime HireDate { get; set; }
|
||||||
|
|||||||
@@ -136,18 +136,7 @@ public interface ICsvImportService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
Task<CsvImportResultDto> ImportVendorsAsync(Stream csvStream, int companyId);
|
Task<CsvImportResultDto> ImportVendorsAsync(Stream csvStream, int companyId);
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
|
||||||
/// Generate a CSV template file for prep service imports.
|
/// Generate a CSV template file for prep service imports.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
byte[] GeneratePrepServiceTemplate();
|
byte[] GeneratePrepServiceTemplate();
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ public class JobProfile : Profile
|
|||||||
// JobTimeEntry → JobTimeEntryDto
|
// JobTimeEntry → JobTimeEntryDto
|
||||||
CreateMap<JobTimeEntry, JobTimeEntryDto>()
|
CreateMap<JobTimeEntry, JobTimeEntryDto>()
|
||||||
.ForMember(dest => dest.WorkerName, opt => opt.MapFrom(src =>
|
.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
|
// CreateJobDto to Job
|
||||||
CreateMap<CreateJobDto, 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>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -59,6 +59,13 @@ public class ApplicationUser : IdentityUser
|
|||||||
public string? SidebarColor { get; set; } = "ocean";
|
public string? SidebarColor { get; set; } = "ocean";
|
||||||
public string? Notes { get; set; }
|
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 CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime? UpdatedAt { get; set; }
|
public DateTime? UpdatedAt { get; set; }
|
||||||
public DateTime? LastLoginDate { 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<Quote> Quotes { get; set; } = new List<Quote>();
|
||||||
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
|
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
|
||||||
public virtual ICollection<Vendor> Vendors { get; set; } = new List<Vendor>();
|
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 CompanyOperatingCosts? OperatingCosts { get; set; }
|
||||||
public virtual CompanyPreferences? Preferences { get; set; }
|
public virtual CompanyPreferences? Preferences { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ namespace PowderCoating.Core.Entities
|
|||||||
[Range(0, 10000)]
|
[Range(0, 10000)]
|
||||||
public decimal StandardLaborRate { get; set; }
|
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)
|
// Additional Coat Labor Percentage (percentage of base labor for each additional coat beyond the first)
|
||||||
[Range(0, 100)]
|
[Range(0, 100)]
|
||||||
public decimal AdditionalCoatLaborPercent { get; set; } = 30m;
|
public decimal AdditionalCoatLaborPercent { get; set; } = 30m;
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ namespace PowderCoating.Core.Entities;
|
|||||||
public class JobTimeEntry : BaseEntity
|
public class JobTimeEntry : BaseEntity
|
||||||
{
|
{
|
||||||
public int JobId { get; set; }
|
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? UserId { get; set; } // FK to AspNetUsers
|
||||||
public string? UserDisplayName { get; set; } // snapshot of worker name at entry creation time
|
public string? UserDisplayName { get; set; } // snapshot of worker name at entry creation time
|
||||||
public DateTime WorkDate { get; set; }
|
public DateTime WorkDate { get; set; }
|
||||||
@@ -13,5 +12,4 @@ public class JobTimeEntry : BaseEntity
|
|||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
public virtual Job Job { get; set; } = null!;
|
public virtual Job Job { get; set; } = null!;
|
||||||
public virtual ShopWorker? Worker { get; set; } // nullable — only populated for legacy entries
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
Retired = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum ShopWorkerRole
|
|
||||||
{
|
|
||||||
GeneralLabor = 0,
|
|
||||||
Sandblaster = 1,
|
|
||||||
Coater = 2,
|
|
||||||
Masker = 3,
|
|
||||||
QualityControl = 4,
|
|
||||||
OvenOperator = 5,
|
|
||||||
Supervisor = 6,
|
|
||||||
Maintenance = 7
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum JobPhotoType
|
public enum JobPhotoType
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -54,9 +54,7 @@ public interface IUnitOfWork : IDisposable
|
|||||||
IRepository<AppointmentStatusLookup> AppointmentStatusLookups { get; }
|
IRepository<AppointmentStatusLookup> AppointmentStatusLookups { get; }
|
||||||
IRepository<AppointmentTypeLookup> AppointmentTypeLookups { get; }
|
IRepository<AppointmentTypeLookup> AppointmentTypeLookups { get; }
|
||||||
IRepository<PrepService> PrepServices { get; }
|
IRepository<PrepService> PrepServices { get; }
|
||||||
IRepository<ShopWorker> ShopWorkers { get; }
|
IRepository<ReworkRecord> ReworkRecords { get; }
|
||||||
IRepository<ShopWorkerRoleCost> ShopWorkerRoleCosts { get; }
|
|
||||||
IRepository<ReworkRecord> ReworkRecords { get; }
|
|
||||||
IRepository<Refund> Refunds { get; }
|
IRepository<Refund> Refunds { get; }
|
||||||
IRepository<CreditMemo> CreditMemos { get; }
|
IRepository<CreditMemo> CreditMemos { get; }
|
||||||
IRepository<CreditMemoApplication> CreditMemoApplications { get; }
|
IRepository<CreditMemoApplication> CreditMemoApplications { get; }
|
||||||
|
|||||||
@@ -205,11 +205,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
public DbSet<MaintenanceRecord> MaintenanceRecords { get; set; }
|
public DbSet<MaintenanceRecord> MaintenanceRecords { get; set; }
|
||||||
/// <summary>Supplier/vendor records used by Purchasing and Accounts Payable; tenant-filtered with soft delete.</summary>
|
/// <summary>Supplier/vendor records used by Purchasing and Accounts Payable; tenant-filtered with soft delete.</summary>
|
||||||
public DbSet<Vendor> Vendors { get; set; }
|
public DbSet<Vendor> Vendors { get; set; }
|
||||||
/// <summary>Shop worker profiles with role assignments; 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<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>
|
|
||||||
public DbSet<ReworkRecord> ReworkRecords { get; set; }
|
public DbSet<ReworkRecord> ReworkRecords { get; set; }
|
||||||
/// <summary>Customer refund records; tenant-filtered with soft delete.</summary>
|
/// <summary>Customer refund records; tenant-filtered with soft delete.</summary>
|
||||||
public DbSet<Refund> Refunds { get; set; }
|
public DbSet<Refund> Refunds { get; set; }
|
||||||
@@ -530,11 +526,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
modelBuilder.Entity<JobStatusHistory>().HasQueryFilter(e =>
|
modelBuilder.Entity<JobStatusHistory>().HasQueryFilter(e =>
|
||||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
modelBuilder.Entity<ShopWorker>().HasQueryFilter(e =>
|
modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
||||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
|
||||||
modelBuilder.Entity<ShopWorkerRoleCost>().HasQueryFilter(e =>
|
|
||||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
|
||||||
modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
|
||||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
modelBuilder.Entity<Refund>().HasQueryFilter(e =>
|
modelBuilder.Entity<Refund>().HasQueryFilter(e =>
|
||||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
@@ -1314,12 +1306,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
.HasForeignKey(m => m.PerformedById)
|
.HasForeignKey(m => m.PerformedById)
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
// ShopWorker relationships
|
|
||||||
modelBuilder.Entity<ShopWorker>()
|
|
||||||
.HasOne<Company>()
|
|
||||||
.WithMany(c => c.ShopWorkers)
|
|
||||||
.HasForeignKey(e => e.CompanyId)
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
modelBuilder.Entity<Job>()
|
modelBuilder.Entity<Job>()
|
||||||
.HasOne(j => j.AssignedUser)
|
.HasOne(j => j.AssignedUser)
|
||||||
@@ -1393,10 +1380,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
modelBuilder.Entity<PricingTier>()
|
modelBuilder.Entity<PricingTier>()
|
||||||
.HasIndex(p => p.CompanyId);
|
.HasIndex(p => p.CompanyId);
|
||||||
|
|
||||||
modelBuilder.Entity<ShopWorker>()
|
modelBuilder.Entity<CatalogCategory>()
|
||||||
.HasIndex(w => w.CompanyId);
|
|
||||||
|
|
||||||
modelBuilder.Entity<CatalogCategory>()
|
|
||||||
.HasIndex(c => c.CompanyId);
|
.HasIndex(c => c.CompanyId);
|
||||||
|
|
||||||
modelBuilder.Entity<CatalogCategory>()
|
modelBuilder.Entity<CatalogCategory>()
|
||||||
@@ -1431,12 +1415,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
.IsUnique()
|
.IsUnique()
|
||||||
.HasDatabaseName("IX_Jobs_CompanyId_JobNumber");
|
.HasDatabaseName("IX_Jobs_CompanyId_JobNumber");
|
||||||
|
|
||||||
modelBuilder.Entity<ShopWorkerRoleCost>()
|
modelBuilder.Entity<Job>()
|
||||||
.HasIndex(r => new { r.CompanyId, r.Role })
|
|
||||||
.IsUnique()
|
|
||||||
.HasDatabaseName("IX_ShopWorkerRoleCosts_CompanyId_Role");
|
|
||||||
|
|
||||||
modelBuilder.Entity<Job>()
|
|
||||||
.Property(j => j.ShopAccessCode)
|
.Property(j => j.ShopAccessCode)
|
||||||
.HasDefaultValueSql("NEWID()");
|
.HasDefaultValueSql("NEWID()");
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ public class AuditInterceptor : SaveChangesInterceptor
|
|||||||
private static readonly HashSet<string> AuditedTypes = new(StringComparer.Ordinal)
|
private static readonly HashSet<string> AuditedTypes = new(StringComparer.Ordinal)
|
||||||
{
|
{
|
||||||
nameof(Customer), nameof(Job), nameof(Quote), nameof(Equipment),
|
nameof(Customer), nameof(Job), nameof(Quote), nameof(Equipment),
|
||||||
nameof(MaintenanceRecord), nameof(Vendor), nameof(ShopWorker),
|
nameof(MaintenanceRecord), nameof(Vendor),
|
||||||
nameof(InventoryItem), nameof(Company),
|
nameof(InventoryItem), nameof(Company),
|
||||||
// Financial entities
|
// Financial entities
|
||||||
nameof(Invoice), nameof(Payment), nameof(Bill), nameof(BillPayment),
|
nameof(Invoice), nameof(Payment), nameof(Bill), nameof(BillPayment),
|
||||||
|
|||||||
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")
|
b.Property<bool>("IsBanned")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<decimal?>("LaborCostPerHour")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<DateTime?>("LastLoginDate")
|
b.Property<DateTime?>("LastLoginDate")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
@@ -2075,6 +2078,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<bool>("IsDeleted")
|
b.Property<bool>("IsDeleted")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<decimal?>("LaborCostPerHour")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<int>("MonthlyBillableHours")
|
b.Property<int>("MonthlyBillableHours")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -6714,7 +6720,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(845),
|
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3131),
|
||||||
Description = "Standard pricing for regular customers",
|
Description = "Standard pricing for regular customers",
|
||||||
DiscountPercent = 0m,
|
DiscountPercent = 0m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6725,7 +6731,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(850),
|
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3137),
|
||||||
Description = "5% discount for preferred customers",
|
Description = "5% discount for preferred customers",
|
||||||
DiscountPercent = 5m,
|
DiscountPercent = 5m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6736,7 +6742,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 3,
|
Id = 3,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(852),
|
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3138),
|
||||||
Description = "10% discount for premium customers",
|
Description = "10% discount for premium customers",
|
||||||
DiscountPercent = 10m,
|
DiscountPercent = 10m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
|
|||||||
@@ -171,7 +171,6 @@ public class JobRepository : Repository<Job>, IJobRepository
|
|||||||
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
|
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
|
||||||
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
|
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
|
||||||
.Include(j => j.TimeEntries.Where(t => !t.IsDeleted))
|
.Include(j => j.TimeEntries.Where(t => !t.IsDeleted))
|
||||||
.ThenInclude(t => t.Worker)
|
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,6 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
private IRepository<AppointmentStatusLookup>? _appointmentStatusLookups;
|
private IRepository<AppointmentStatusLookup>? _appointmentStatusLookups;
|
||||||
private IRepository<AppointmentTypeLookup>? _appointmentTypeLookups;
|
private IRepository<AppointmentTypeLookup>? _appointmentTypeLookups;
|
||||||
private IRepository<PrepService>? _prepServices;
|
private IRepository<PrepService>? _prepServices;
|
||||||
private IRepository<ShopWorker>? _shopWorkers;
|
|
||||||
|
|
||||||
// Appointments
|
// Appointments
|
||||||
private IRepository<Appointment>? _appointments;
|
private IRepository<Appointment>? _appointments;
|
||||||
@@ -350,16 +349,7 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
public IRepository<PrepService> PrepServices =>
|
public IRepository<PrepService> PrepServices =>
|
||||||
_prepServices ??= new Repository<PrepService>(_context);
|
_prepServices ??= new Repository<PrepService>(_context);
|
||||||
|
|
||||||
/// <summary>Repository for <see cref="ShopWorker"/> profiles with role assignments; tenant-filtered with soft delete.</summary>
|
/// <summary>Repository for <see cref="ReworkRecord"/> quality-failure and remediation records; 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>
|
|
||||||
private IRepository<ReworkRecord>? _reworkRecords;
|
private IRepository<ReworkRecord>? _reworkRecords;
|
||||||
public IRepository<ReworkRecord> ReworkRecords =>
|
public IRepository<ReworkRecord> ReworkRecords =>
|
||||||
_reworkRecords ??= new Repository<ReworkRecord>(_context);
|
_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.NotificationTemplates.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||||
await _context.Announcements.Where(x => x.TargetCompanyId == companyId).ExecuteDeleteAsync();
|
await _context.Announcements.Where(x => x.TargetCompanyId == companyId).ExecuteDeleteAsync();
|
||||||
await _context.BugReports.IgnoreQueryFilters().Where(x => x.CompanyId == 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();
|
await _context.PrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||||
|
|
||||||
// ── Tier 4: Company configs and lookup tables ─────────────────────────
|
// ── 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.PurchaseOrderItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||||
await _context.AiItemPredictions.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.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.OvenBatches.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||||
await _context.Refunds.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();
|
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.OvenCosts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||||
await _context.Accounts.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.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.PrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||||
await _context.CatalogCategories.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();
|
await _context.InventoryCategoryLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using CsvHelper;
|
using CsvHelper;
|
||||||
using CsvHelper.Configuration;
|
using CsvHelper.Configuration;
|
||||||
@@ -2164,168 +2164,6 @@ public class CsvImportService : ICsvImportService
|
|||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#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
|
#region Prep Service Import
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -133,7 +133,6 @@ public class AccountDataExportController : Controller
|
|||||||
case "Inventory": await AddInventorySheet(package, companyId, headerColor); break;
|
case "Inventory": await AddInventorySheet(package, companyId, headerColor); break;
|
||||||
case "Equipment": await AddEquipmentSheet(package, companyId, headerColor); break;
|
case "Equipment": await AddEquipmentSheet(package, companyId, headerColor); break;
|
||||||
case "Vendors": await AddVendorsSheet(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;
|
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 "Inventory": WriteCsvEntry(zip, "Inventory.csv", await BuildInventoryCsv(companyId)); break;
|
||||||
case "Equipment": WriteCsvEntry(zip, "Equipment.csv", await BuildEquipmentCsv(companyId)); break;
|
case "Equipment": WriteCsvEntry(zip, "Equipment.csv", await BuildEquipmentCsv(companyId)); break;
|
||||||
case "Vendors": WriteCsvEntry(zip, "Vendors.csv", await BuildVendorsCsv(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;
|
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)
|
.Where(s => s.CompanyId == companyId && !s.IsDeleted)
|
||||||
.OrderBy(s => s.CompanyName).ToListAsync();
|
.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>
|
/// <summary>
|
||||||
/// Fetches all users for the company. <c>IsDeleted</c> is intentionally omitted because
|
/// 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.
|
/// 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);
|
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>
|
/// <summary>
|
||||||
/// Adds a "Users" worksheet. All users (active and inactive) are included because Identity
|
/// 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.
|
/// 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();
|
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>
|
/// <summary>
|
||||||
/// All users (active and inactive) are exported for completeness and compliance — mirrors
|
/// All users (active and inactive) are exported for completeness and compliance — mirrors
|
||||||
/// the reasoning in <see cref="AddUsersSheet"/> and <see cref="FetchUsersAsync"/>.
|
/// the reasoning in <see cref="AddUsersSheet"/> and <see cref="FetchUsersAsync"/>.
|
||||||
@@ -675,13 +639,13 @@ public class AccountDataExportController : Controller
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the subset of selected sheet names reordered into the canonical export sequence
|
/// 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.
|
/// 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.
|
/// Sheet names not in the canonical list are silently dropped.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static string[] OrderSheets(string[] sheets)
|
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();
|
return order.Where(sheets.Contains).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -756,7 +756,6 @@ public class CompanySettingsController : Controller
|
|||||||
var costs = company.OperatingCosts;
|
var costs = company.OperatingCosts;
|
||||||
|
|
||||||
var ovens = (await _unitOfWork.OvenCosts.FindAsync(o => o.IsActive)).OrderBy(o => o.DisplayOrder).ToList();
|
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 coatingCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsCoating)).ToList();
|
||||||
|
|
||||||
var sb = new System.Text.StringBuilder();
|
var sb = new System.Text.StringBuilder();
|
||||||
@@ -783,8 +782,7 @@ public class CompanySettingsController : Controller
|
|||||||
ShopCapabilityTier.Large => "high-volume",
|
ShopCapabilityTier.Large => "high-volume",
|
||||||
_ => "small"
|
_ => "small"
|
||||||
};
|
};
|
||||||
sb.AppendLine($"We are a {tierLabel} operation" +
|
sb.AppendLine($"We are a {tierLabel} operation.");
|
||||||
(workers.Count > 0 ? $" with {workers.Count} active shop worker{(workers.Count == 1 ? "" : "s")}." : "."));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ovens
|
// Ovens
|
||||||
@@ -827,32 +825,6 @@ public class CompanySettingsController : Controller
|
|||||||
sb.AppendLine($"Powder categories we stock: {string.Join(", ", catNames)}.");
|
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
|
// Rates hint
|
||||||
if (costs != null && costs.StandardLaborRate > 0)
|
if (costs != null && costs.StandardLaborRate > 0)
|
||||||
{
|
{
|
||||||
@@ -2719,79 +2691,6 @@ public class CompanySettingsController : Controller
|
|||||||
|
|
||||||
// ── Role-Based Labor Rates ────────────────────────────────────────────────
|
// ── 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 ───────────────────────────────────────────────────────
|
// ─── Stripe Connect ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -3055,7 +2954,6 @@ public class CompanySettingsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
public record SaveTemplateJsonRequest(int Id, string? Subject, string? Body);
|
public record SaveTemplateJsonRequest(int Id, string? Subject, string? Body);
|
||||||
public record SaveRoleCostDto(int Role, decimal HourlyRate);
|
|
||||||
public record SaveOnlinePaymentSettingsDto(
|
public record SaveOnlinePaymentSettingsDto(
|
||||||
OnlinePaymentSurchargeType SurchargeType,
|
OnlinePaymentSurchargeType SurchargeType,
|
||||||
decimal SurchargeValue,
|
decimal SurchargeValue,
|
||||||
|
|||||||
@@ -226,11 +226,9 @@ public class CompanyUsersController : Controller
|
|||||||
/// Creates a new company user, enforcing the subscription user-count limit and a whitelist
|
/// 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
|
/// of valid <c>CompanyRole</c> values (preventing callers from submitting a null role to
|
||||||
/// create a SuperAdmin-equivalent account). CompanyAdmin users automatically receive all
|
/// create a SuperAdmin-equivalent account). CompanyAdmin users automatically receive all
|
||||||
/// per-feature permissions unless a SuperAdmin is explicitly customising them. Workers
|
/// per-feature permissions unless a SuperAdmin is explicitly customising them. A legacy
|
||||||
/// additionally get an auto-created <see cref="ShopWorker"/> record so they appear in job
|
/// ASP.NET Identity role (Administrator / Manager / Employee / ReadOnly) is also assigned
|
||||||
/// assignment dropdowns without a separate onboarding step. A legacy ASP.NET Identity role
|
/// to satisfy policy checks that still reference the role system.
|
||||||
/// (Administrator / Manager / Employee / ReadOnly) is also assigned to satisfy policy
|
|
||||||
/// checks that still reference the role system.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
// POST: CompanyUsers/Create
|
// POST: CompanyUsers/Create
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
@@ -351,27 +349,7 @@ public class CompanyUsersController : Controller
|
|||||||
|
|
||||||
await _userManager.AddToRoleAsync(user, legacyRole);
|
await _userManager.AddToRoleAsync(user, legacyRole);
|
||||||
|
|
||||||
// If Worker role, automatically create a ShopWorker record
|
_logger.LogInformation("User {Email} created successfully by {Admin}",
|
||||||
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}",
|
|
||||||
user.Email, User.Identity?.Name);
|
user.Email, User.Identity?.Name);
|
||||||
|
|
||||||
TempData["Success"] = $"User '{user.FullName}' created successfully.";
|
TempData["Success"] = $"User '{user.FullName}' created successfully.";
|
||||||
@@ -441,6 +419,7 @@ public class CompanyUsersController : Controller
|
|||||||
CompanyRole = user.CompanyRole ?? AppConstants.CompanyRoles.Viewer,
|
CompanyRole = user.CompanyRole ?? AppConstants.CompanyRoles.Viewer,
|
||||||
Department = user.Department,
|
Department = user.Department,
|
||||||
Position = user.Position,
|
Position = user.Position,
|
||||||
|
LaborCostPerHour = user.LaborCostPerHour,
|
||||||
Phone = user.PhoneNumber,
|
Phone = user.PhoneNumber,
|
||||||
IsActive = user.IsActive,
|
IsActive = user.IsActive,
|
||||||
HireDate = user.HireDate,
|
HireDate = user.HireDate,
|
||||||
@@ -479,11 +458,9 @@ public class CompanyUsersController : Controller
|
|||||||
/// Saves changes to an existing company user. Validates company isolation and role whitelist
|
/// 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
|
/// (same checks as <see cref="Edit(string, string)"/>). Prevents two dangerous deactivation
|
||||||
/// scenarios: a user deactivating themselves, and deactivating the last active CompanyAdmin
|
/// 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
|
/// for a company (which would lock out the tenant). Email changes are applied via
|
||||||
/// matching <see cref="ShopWorker"/> record exists, one is created automatically; if one
|
/// <c>SetEmailAsync</c> / <c>SetUserNameAsync</c> after the main update so Identity's own
|
||||||
/// already exists, its name, email, and active status are kept in sync. Email changes are
|
/// normalisation logic runs correctly.
|
||||||
/// applied via <c>SetEmailAsync</c> / <c>SetUserNameAsync</c> after the main update so
|
|
||||||
/// Identity's own normalisation logic runs correctly.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
// POST: CompanyUsers/Edit/id
|
// POST: CompanyUsers/Edit/id
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
@@ -596,6 +573,7 @@ public class CompanyUsersController : Controller
|
|||||||
user.CompanyRole = model.CompanyRole;
|
user.CompanyRole = model.CompanyRole;
|
||||||
user.Department = model.Department;
|
user.Department = model.Department;
|
||||||
user.Position = model.Position;
|
user.Position = model.Position;
|
||||||
|
user.LaborCostPerHour = model.LaborCostPerHour;
|
||||||
user.PhoneNumber = model.Phone;
|
user.PhoneNumber = model.Phone;
|
||||||
user.IsActive = model.IsActive;
|
user.IsActive = model.IsActive;
|
||||||
user.HireDate = model.HireDate;
|
user.HireDate = model.HireDate;
|
||||||
@@ -632,60 +610,7 @@ public class CompanyUsersController : Controller
|
|||||||
user.Id, oldEmail, model.Email, User.Identity?.Name);
|
user.Id, oldEmail, model.Email, User.Identity?.Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If role changed to Worker, ensure ShopWorker record exists
|
_logger.LogInformation("User {Email} updated successfully by {Admin}",
|
||||||
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}",
|
|
||||||
user.Email, User.Identity?.Name);
|
user.Email, User.Identity?.Name);
|
||||||
|
|
||||||
TempData["Success"] = "User updated successfully.";
|
TempData["Success"] = "User updated successfully.";
|
||||||
|
|||||||
@@ -122,7 +122,6 @@ public class DataExportController : Controller
|
|||||||
case "Inventory": await AddInventorySheet(package, companyId, headerColor); break;
|
case "Inventory": await AddInventorySheet(package, companyId, headerColor); break;
|
||||||
case "Equipment": await AddEquipmentSheet(package, companyId, headerColor); break;
|
case "Equipment": await AddEquipmentSheet(package, companyId, headerColor); break;
|
||||||
case "Vendors": await AddVendorsSheet(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;
|
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 "Inventory": WriteCsvEntry(zip, "Inventory.csv", await BuildInventoryCsv(companyId)); break;
|
||||||
case "Equipment": WriteCsvEntry(zip, "Equipment.csv", await BuildEquipmentCsv(companyId)); break;
|
case "Equipment": WriteCsvEntry(zip, "Equipment.csv", await BuildEquipmentCsv(companyId)); break;
|
||||||
case "Vendors": WriteCsvEntry(zip, "Vendors.csv", await BuildVendorsCsv(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;
|
case "Users": WriteCsvEntry(zip, "Users.csv", await BuildUsersCsv(companyId)); break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -441,38 +439,6 @@ public class DataExportController : Controller
|
|||||||
AutoFit(ws, headers.Length);
|
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>
|
/// <summary>
|
||||||
/// Adds an "Invoices" worksheet with one row per non-deleted invoice for the specified company.
|
/// 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
|
/// 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();
|
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>
|
/// <summary>
|
||||||
/// Builds the users CSV string for the specified company, ordered by last name.
|
/// 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
|
/// Like <see cref="AddUsersSheet"/>, the <c>IsDeleted</c> filter is intentionally omitted
|
||||||
@@ -761,7 +712,7 @@ public class DataExportController : Controller
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the requested sheet names sorted into the canonical export order
|
/// Returns the requested sheet names sorted into the canonical export order
|
||||||
/// (Customers → Jobs → Quotes → Invoices → Inventory → Equipment → Vendors → ShopWorkers → Users).
|
/// (Customers → Jobs → Quotes → Invoices → Inventory → Equipment → Vendors → Users).
|
||||||
/// This ensures that the workbook and ZIP archive always have a predictable, logical layout
|
/// This ensures that the workbook and ZIP archive always have a predictable, logical layout
|
||||||
/// regardless of the order the administrator checked the boxes on the form.
|
/// regardless of the order the administrator checked the boxes on the form.
|
||||||
/// Any sheet name not in the canonical list is silently ignored.
|
/// Any sheet name not in the canonical list is silently ignored.
|
||||||
@@ -769,7 +720,7 @@ public class DataExportController : Controller
|
|||||||
/// <param name="sheets">Raw sheet names from the form POST.</param>
|
/// <param name="sheets">Raw sheet names from the form POST.</param>
|
||||||
private static string[] OrderSheets(string[] sheets)
|
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();
|
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("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("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("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;
|
return stats;
|
||||||
}
|
}
|
||||||
@@ -204,7 +203,6 @@ public class DataPurgeController : Controller
|
|||||||
"Equipment" => await QueryCount(_db.Equipment, cutoff),
|
"Equipment" => await QueryCount(_db.Equipment, cutoff),
|
||||||
"MaintenanceRecords" => await QueryCount(_db.MaintenanceRecords, cutoff),
|
"MaintenanceRecords" => await QueryCount(_db.MaintenanceRecords, cutoff),
|
||||||
"Vendors" => await QueryCount(_db.Vendors, cutoff),
|
"Vendors" => await QueryCount(_db.Vendors, cutoff),
|
||||||
"ShopWorkers" => await QueryCount(_db.ShopWorkers, cutoff),
|
|
||||||
_ => (0, null)
|
_ => (0, null)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -324,11 +322,6 @@ public class DataPurgeController : Controller
|
|||||||
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
|
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "ShopWorkers":
|
|
||||||
count = await _db.ShopWorkers.IgnoreQueryFilters()
|
|
||||||
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -359,7 +352,7 @@ public class DataPurgeController : Controller
|
|||||||
"MaintenanceRecords",
|
"MaintenanceRecords",
|
||||||
"Jobs", "Customers", "Quotes",
|
"Jobs", "Customers", "Quotes",
|
||||||
"InventoryItems", "Equipment",
|
"InventoryItems", "Equipment",
|
||||||
"Vendors", "ShopWorkers"
|
"Vendors"
|
||||||
};
|
};
|
||||||
return order.Where(entities.Contains).ToArray();
|
return order.Where(entities.Contains).ToArray();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,14 +38,6 @@ namespace PowderCoating.Web.Controllers
|
|||||||
return View();
|
return View();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Serves the Shop Workers help article describing roles, assignment to jobs, and maintenance tasks.
|
|
||||||
/// </summary>
|
|
||||||
public IActionResult ShopWorkers()
|
|
||||||
{
|
|
||||||
return View();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Serves the Equipment help article explaining the equipment status lifecycle and maintenance scheduling.
|
/// Serves the Equipment help article explaining the equipment status lifecycle and maintenance scheduling.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -3368,8 +3368,7 @@ public class JobsController : Controller
|
|||||||
public async Task<IActionResult> GetTimeEntries(int jobId)
|
public async Task<IActionResult> GetTimeEntries(int jobId)
|
||||||
{
|
{
|
||||||
var entries = await _unitOfWork.JobTimeEntries.FindAsync(
|
var entries = await _unitOfWork.JobTimeEntries.FindAsync(
|
||||||
e => e.JobId == jobId, false,
|
e => e.JobId == jobId, false);
|
||||||
e => e.Worker); // Worker nav loaded for display of legacy entries that pre-date user migration
|
|
||||||
var dtos = _mapper.Map<List<JobTimeEntryDto>>(entries.OrderByDescending(e => e.WorkDate).ToList());
|
var dtos = _mapper.Map<List<JobTimeEntryDto>>(entries.OrderByDescending(e => e.WorkDate).ToList());
|
||||||
return Json(dtos);
|
return Json(dtos);
|
||||||
}
|
}
|
||||||
@@ -3823,15 +3822,24 @@ public class JobsController : Controller
|
|||||||
|
|
||||||
// Operating costs for fallback labor rate and oven rate
|
// Operating costs for fallback labor rate and oven rate
|
||||||
var opCosts = (await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == companyId)).FirstOrDefault();
|
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 effectiveOvenMinutes = (opCosts?.DefaultOvenCycleMinutes > 0 ? (int?)opCosts!.DefaultOvenCycleMinutes : null) ?? 45;
|
||||||
var defaultOvenCycleHours = effectiveOvenMinutes / 60.0m;
|
var defaultOvenCycleHours = effectiveOvenMinutes / 60.0m;
|
||||||
|
|
||||||
// Role cost rates map: role → hourly rate
|
// Labor cost rate priority: per-user LaborCostPerHour → company LaborCostPerHour → 20% of StandardLaborRate
|
||||||
var roleCosts = await _unitOfWork.ShopWorkerRoleCosts.FindAsync(r => r.CompanyId == companyId);
|
var companyLaborCostRate = opCosts?.LaborCostPerHour ?? ((opCosts?.StandardLaborRate ?? 0m) * 0.20m);
|
||||||
var roleCostMap = roleCosts.ToDictionary(r => r.Role, r => r.HourlyRate);
|
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
|
// 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;
|
decimal powderCost = 0m;
|
||||||
var powderLines = new List<object>();
|
var powderLines = new List<object>();
|
||||||
bool hasCoatsWithRateButNoQty = false;
|
bool hasCoatsWithRateButNoQty = false;
|
||||||
@@ -3839,7 +3847,19 @@ public class JobsController : Controller
|
|||||||
{
|
{
|
||||||
foreach (var coat in item.Coats)
|
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 costPerLb = coat.PowderCostPerLb ?? 0m;
|
||||||
var lineCost = lbs * costPerLb;
|
var lineCost = lbs * costPerLb;
|
||||||
powderCost += lineCost;
|
powderCost += lineCost;
|
||||||
@@ -3850,7 +3870,7 @@ public class JobsController : Controller
|
|||||||
lbs = Math.Round(lbs, 3),
|
lbs = Math.Round(lbs, 3),
|
||||||
costPerLb = Math.Round(costPerLb, 4),
|
costPerLb = Math.Round(costPerLb, 4),
|
||||||
total = Math.Round(lineCost, 2),
|
total = Math.Round(lineCost, 2),
|
||||||
isActual = coat.ActualPowderUsedLbs.HasValue
|
isActual
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
else if (costPerLb > 0 && lbs == 0)
|
else if (costPerLb > 0 && lbs == 0)
|
||||||
@@ -3862,20 +3882,23 @@ public class JobsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Labor cost
|
// 2. Labor cost
|
||||||
|
// Priority: per-user LaborCostPerHour → company LaborCostPerHour → 20% of StandardLaborRate
|
||||||
decimal laborCost = 0m;
|
decimal laborCost = 0m;
|
||||||
var laborLines = new List<object>();
|
var laborLines = new List<object>();
|
||||||
foreach (var entry in job.TimeEntries)
|
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;
|
var lineCost = entry.HoursWorked * rate;
|
||||||
laborCost += lineCost;
|
laborCost += lineCost;
|
||||||
laborLines.Add(new {
|
laborLines.Add(new {
|
||||||
worker = entry.Worker?.Name ?? "Unknown",
|
worker = entry.UserDisplayName ?? "Unknown",
|
||||||
role = entry.Worker != null ? System.Text.RegularExpressions.Regex.Replace(entry.Worker.Role.ToString(), "([a-z])([A-Z])", "$1 $2") : "",
|
|
||||||
hours = entry.HoursWorked,
|
hours = entry.HoursWorked,
|
||||||
rate = Math.Round(rate, 2),
|
rate = Math.Round(rate, 2),
|
||||||
total = Math.Round(lineCost, 2),
|
total = Math.Round(lineCost, 2),
|
||||||
usingFallback = entry.Worker == null || !roleCostMap.ContainsKey(entry.Worker.Role),
|
usingFallback = !usingPerUser,
|
||||||
stage = entry.Stage,
|
stage = entry.Stage,
|
||||||
workDate = entry.WorkDate.ToString("MM/dd/yyyy")
|
workDate = entry.WorkDate.ToString("MM/dd/yyyy")
|
||||||
});
|
});
|
||||||
@@ -3949,7 +3972,7 @@ public class JobsController : Controller
|
|||||||
grossMargin,
|
grossMargin,
|
||||||
quotedMargin,
|
quotedMargin,
|
||||||
quotedPrice = Math.Round(job.QuotedPrice, 2),
|
quotedPrice = Math.Round(job.QuotedPrice, 2),
|
||||||
fallbackLaborRate,
|
companyLaborCostRate,
|
||||||
powderLines,
|
powderLines,
|
||||||
laborLines,
|
laborLines,
|
||||||
hasPowderData = powderLines.Count > 0,
|
hasPowderData = powderLines.Count > 0,
|
||||||
|
|||||||
@@ -911,7 +911,7 @@ public class ToolsController : Controller
|
|||||||
/// <c>CompanyId</c> provides the multi-tenant isolation that global query filters would
|
/// <c>CompanyId</c> provides the multi-tenant isolation that global query filters would
|
||||||
/// normally enforce for other entity types.
|
/// normally enforce for other entity types.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
// GET: Tools/GetShopWorkers - For randomizer wheel
|
// GET: Tools/GetShopWorkers - Returns active company users for randomizer wheel
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> GetShopWorkers()
|
public async Task<IActionResult> GetShopWorkers()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1219,7 +1219,6 @@ public static class HelpKnowledgeBase
|
|||||||
- [Accounts Payable](/Help/AccountsPayable)
|
- [Accounts Payable](/Help/AccountsPayable)
|
||||||
- [Equipment & Maintenance](/Help/Equipment)
|
- [Equipment & Maintenance](/Help/Equipment)
|
||||||
- [Vendors](/Help/Vendors)
|
- [Vendors](/Help/Vendors)
|
||||||
- [Shop Workers](/Help/ShopWorkers)
|
|
||||||
- [Reports](/Help/Reports)
|
- [Reports](/Help/Reports)
|
||||||
- [Settings](/Help/Settings)
|
- [Settings](/Help/Settings)
|
||||||
- [User Profile](/Help/UserProfile)
|
- [User Profile](/Help/UserProfile)
|
||||||
|
|||||||
@@ -270,8 +270,7 @@ builder.Services.AddSingleton<IMapper>(sp =>
|
|||||||
cfg.AddProfile(new InventoryProfile());
|
cfg.AddProfile(new InventoryProfile());
|
||||||
cfg.AddProfile(new EquipmentProfile());
|
cfg.AddProfile(new EquipmentProfile());
|
||||||
cfg.AddProfile(new MaintenanceProfile());
|
cfg.AddProfile(new MaintenanceProfile());
|
||||||
cfg.AddProfile(new ShopWorkerProfile());
|
cfg.AddProfile(new CatalogProfile());
|
||||||
cfg.AddProfile(new CatalogProfile());
|
|
||||||
cfg.AddProfile(new VendorProfile());
|
cfg.AddProfile(new VendorProfile());
|
||||||
cfg.AddProfile(new LookupProfile());
|
cfg.AddProfile(new LookupProfile());
|
||||||
cfg.AddProfile(new AppointmentProfile());
|
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["Title"] = "Company Settings";
|
||||||
ViewData["PageIcon"] = "bi-building";
|
ViewData["PageIcon"] = "bi-building";
|
||||||
@@ -344,9 +344,12 @@
|
|||||||
|
|
||||||
<!-- Operating Costs Tab -->
|
<!-- Operating Costs Tab -->
|
||||||
<div class="tab-pane fade" id="operating-costs" role="tabpanel">
|
<div class="tab-pane fade" id="operating-costs" role="tabpanel">
|
||||||
|
<form id="operatingCostsForm">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
<div class="card mt-3">
|
<div class="card mt-3">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title">Operating Costs Configuration
|
<h5 class="card-title mb-1">Operating Costs Configuration
|
||||||
<a tabindex="0" class="help-icon" role="button"
|
<a tabindex="0" class="help-icon" role="button"
|
||||||
data-bs-toggle="popover" data-bs-placement="right"
|
data-bs-toggle="popover" data-bs-placement="right"
|
||||||
data-bs-title="Operating Costs"
|
data-bs-title="Operating Costs"
|
||||||
@@ -354,18 +357,22 @@
|
|||||||
<i class="bi bi-question-circle"></i>
|
<i class="bi bi-question-circle"></i>
|
||||||
</a>
|
</a>
|
||||||
</h5>
|
</h5>
|
||||||
<p class="text-muted">Configure your operating costs for accurate job quoting calculations.</p>
|
<p class="text-muted mb-0">Configure your operating costs for accurate job quoting calculations.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form id="operatingCostsForm">
|
|
||||||
<!-- Rates & Costs -->
|
<!-- Rates & Costs -->
|
||||||
<h6 class="border-bottom pb-2 mb-3">Rates & Costs
|
<div class="card mt-3 border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-transparent fw-semibold">
|
||||||
|
<i class="bi bi-currency-dollar text-primary me-1"></i> Rates & Costs
|
||||||
<a tabindex="0" class="help-icon" role="button"
|
<a tabindex="0" class="help-icon" role="button"
|
||||||
data-bs-toggle="popover" data-bs-placement="right"
|
data-bs-toggle="popover" data-bs-placement="right"
|
||||||
data-bs-title="Rates & Costs"
|
data-bs-title="Rates & Costs"
|
||||||
data-bs-content="<strong>Standard Labor Rate</strong> is the baseline $/hr for all coating work — sandblasting and masking are multiplied from this. <strong>Powder Coating Cost/sq ft</strong> is the fallback material rate used when you don't select a specific powder inventory item on a quote item. <strong>Additional Coat Labor</strong> is the percentage of the base labor cost charged for each coat after the first (e.g. 30% means a 2nd coat adds 30% more labor).">
|
data-bs-content="<strong>Standard Labor Rate</strong> is the baseline $/hr for all coating work — sandblasting and masking are multiplied from this. <strong>Powder Coating Cost/sq ft</strong> is the fallback material rate used when you don't select a specific powder inventory item on a quote item. <strong>Additional Coat Labor</strong> is the percentage of the base labor cost charged for each coat after the first (e.g. 30% means a 2nd coat adds 30% more labor).">
|
||||||
<i class="bi bi-question-circle"></i>
|
<i class="bi bi-question-circle"></i>
|
||||||
</a>
|
</a>
|
||||||
</h6>
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -375,6 +382,18 @@
|
|||||||
<input type="number" step="0.01" class="form-control" id="standardLaborRate" name="StandardLaborRate" value="@(Model.OperatingCosts?.StandardLaborRate ?? 0)" min="0" max="10000" required>
|
<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>
|
<span class="input-group-text">/hr</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
@@ -418,16 +437,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Facility Overhead -->
|
<!-- Facility Overhead -->
|
||||||
<h6 class="border-bottom pb-2 mb-3 mt-3">Facility Overhead
|
<div class="card mt-3 border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-transparent fw-semibold">
|
||||||
|
<i class="bi bi-building text-primary me-1"></i> Facility Overhead
|
||||||
<a tabindex="0" class="help-icon" role="button"
|
<a tabindex="0" class="help-icon" role="button"
|
||||||
data-bs-toggle="popover" data-bs-placement="right"
|
data-bs-toggle="popover" data-bs-placement="right"
|
||||||
data-bs-title="Facility Overhead"
|
data-bs-title="Facility Overhead"
|
||||||
data-bs-content="Enter your monthly shop rent and combined utility costs. The system divides these by your estimated billable hours to derive a per-hour overhead rate, which is then added to every quote proportionally to the estimated job time. This ensures fixed facility costs are recovered across all jobs rather than absorbed into your markup.">
|
data-bs-content="Enter your monthly shop rent and combined utility costs. The system divides these by your estimated billable hours to derive a per-hour overhead rate, which is then added to every quote proportionally to the estimated job time. This ensures fixed facility costs are recovered across all jobs rather than absorbed into your markup.">
|
||||||
<i class="bi bi-question-circle"></i>
|
<i class="bi bi-question-circle"></i>
|
||||||
</a>
|
</a>
|
||||||
</h6>
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
<div class="row align-items-start">
|
<div class="row align-items-start">
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -457,7 +481,7 @@
|
|||||||
<input type="number" step="1" class="form-control facility-overhead-input" id="monthlyBillableHours" name="MonthlyBillableHours" value="@(Model.OperatingCosts?.MonthlyBillableHours ?? 160)" min="1" max="10000">
|
<input type="number" step="1" class="form-control facility-overhead-input" id="monthlyBillableHours" name="MonthlyBillableHours" value="@(Model.OperatingCosts?.MonthlyBillableHours ?? 160)" min="1" max="10000">
|
||||||
<span class="input-group-text">hrs</span>
|
<span class="input-group-text">hrs</span>
|
||||||
</div>
|
</div>
|
||||||
<small class="text-muted">Typical: 160 hrs (4 wks × 40 hrs)</small>
|
<small class="text-muted">Typical: 160 hrs (4 wks × 40 hrs)</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
@@ -472,16 +496,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Equipment Operating Costs -->
|
<!-- Equipment Operating Costs -->
|
||||||
<h6 class="border-bottom pb-2 mb-3 mt-3">Equipment Operating Costs
|
<div class="card mt-3 border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-transparent fw-semibold">
|
||||||
|
<i class="bi bi-tools text-primary me-1"></i> Equipment Operating Costs
|
||||||
<a tabindex="0" class="help-icon" role="button"
|
<a tabindex="0" class="help-icon" role="button"
|
||||||
data-bs-toggle="popover" data-bs-placement="right"
|
data-bs-toggle="popover" data-bs-placement="right"
|
||||||
data-bs-title="Equipment Operating Costs"
|
data-bs-title="Equipment Operating Costs"
|
||||||
data-bs-content="The hourly cost of running each piece of equipment, including energy and depreciation. These are added to quote items based on the prep services selected. The <strong>Default Oven Rate</strong> is used on quotes where no named oven is chosen — add individual shop ovens below if you have multiple ovens with different capacities and costs.">
|
data-bs-content="The hourly cost of running each piece of equipment, including energy and depreciation. These are added to quote items based on the prep services selected. The <strong>Default Oven Rate</strong> is used on quotes where no named oven is chosen — add individual shop ovens below if you have multiple ovens with different capacities and costs.">
|
||||||
<i class="bi bi-question-circle"></i>
|
<i class="bi bi-question-circle"></i>
|
||||||
</a>
|
</a>
|
||||||
</h6>
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -515,45 +544,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
<button type="button" class="btn btn-sm btn-primary" onclick="saveRoleCosts()">
|
</div>
|
||||||
<i class="bi bi-floppy me-1"></i>Save Labor Rates
|
|
||||||
</button>
|
|
||||||
<span id="roleCostSaveStatus" class="ms-2 small"></span>
|
|
||||||
|
|
||||||
<!-- Pricing & Overhead -->
|
<!-- Pricing & Profit -->
|
||||||
<h6 class="border-bottom pb-2 mb-3 mt-4">Pricing & Profit
|
<div class="card mt-3 border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-transparent fw-semibold">
|
||||||
|
<i class="bi bi-graph-up-arrow text-primary me-1"></i> Pricing & Profit
|
||||||
<a tabindex="0" class="help-icon" role="button"
|
<a tabindex="0" class="help-icon" role="button"
|
||||||
data-bs-toggle="popover" data-bs-placement="right"
|
data-bs-toggle="popover" data-bs-placement="right"
|
||||||
data-bs-title="Pricing & Profit"
|
data-bs-title="Pricing & Profit"
|
||||||
data-bs-content="<strong>Markup mode</strong> adds a % on top of material costs only (labor and equipment pass through at cost). <strong>Margin mode</strong> targets a gross margin % of the total selling price — e.g. 30% margin on a $100 cost base gives a $142.86 price. Note: margin % and markup % are not the same number. <strong>Shop Minimum</strong> sets a floor price for any job.">
|
data-bs-content="<strong>Markup mode</strong> adds a % on top of material costs only (labor and equipment pass through at cost). <strong>Margin mode</strong> targets a gross margin % of the total selling price — e.g. 30% margin on a $100 cost base gives a $142.86 price. Note: margin % and markup % are not the same number. <strong>Shop Minimum</strong> sets a floor price for any job.">
|
||||||
<i class="bi bi-question-circle"></i>
|
<i class="bi bi-question-circle"></i>
|
||||||
</a>
|
</a>
|
||||||
</h6>
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
@{
|
@{
|
||||||
var currentPricingMode = (int)(Model.OperatingCosts?.PricingMode ?? PowderCoating.Core.Enums.PricingMode.MarkupOnMaterial);
|
var currentPricingMode = (int)(Model.OperatingCosts?.PricingMode ?? PowderCoating.Core.Enums.PricingMode.MarkupOnMaterial);
|
||||||
}
|
}
|
||||||
@@ -564,14 +569,14 @@
|
|||||||
<input class="form-check-input" type="radio" name="pricingModeRadio" id="pricingModeMarkup" value="0"
|
<input class="form-check-input" type="radio" name="pricingModeRadio" id="pricingModeMarkup" value="0"
|
||||||
@(currentPricingMode == 0 ? "checked" : "") onchange="onPricingModeChange()">
|
@(currentPricingMode == 0 ? "checked" : "") onchange="onPricingModeChange()">
|
||||||
<label class="form-check-label" for="pricingModeMarkup">
|
<label class="form-check-label" for="pricingModeMarkup">
|
||||||
<strong>Markup</strong> — add % to material costs
|
<strong>Markup</strong> — add % to material costs
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input class="form-check-input" type="radio" name="pricingModeRadio" id="pricingModeMargin" value="1"
|
<input class="form-check-input" type="radio" name="pricingModeRadio" id="pricingModeMargin" value="1"
|
||||||
@(currentPricingMode == 1 ? "checked" : "") onchange="onPricingModeChange()">
|
@(currentPricingMode == 1 ? "checked" : "") onchange="onPricingModeChange()">
|
||||||
<label class="form-check-label" for="pricingModeMargin">
|
<label class="form-check-label" for="pricingModeMargin">
|
||||||
<strong>Margin</strong> — target gross margin % of selling price
|
<strong>Margin</strong> — target gross margin % of selling price
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -609,16 +614,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Rush Charges -->
|
<!-- Rush Charges -->
|
||||||
<h6 class="border-bottom pb-2 mb-3 mt-3">Rush Charges
|
<div class="card mt-3 border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-transparent fw-semibold">
|
||||||
|
<i class="bi bi-lightning-charge text-primary me-1"></i> Rush Charges
|
||||||
<a tabindex="0" class="help-icon" role="button"
|
<a tabindex="0" class="help-icon" role="button"
|
||||||
data-bs-toggle="popover" data-bs-placement="right"
|
data-bs-toggle="popover" data-bs-placement="right"
|
||||||
data-bs-title="Rush Charges"
|
data-bs-title="Rush Charges"
|
||||||
data-bs-content="When a quote is marked as a <strong>Rush Job</strong>, this charge is automatically added to the total. Choose <strong>Percentage</strong> to add a % of the subtotal (e.g. 25% rush surcharge) or <strong>Fixed Amount</strong> to add a flat fee (e.g. $75 rush fee). The rush charge appears as its own line on the quote.">
|
data-bs-content="When a quote is marked as a <strong>Rush Job</strong>, this charge is automatically added to the total. Choose <strong>Percentage</strong> to add a % of the subtotal (e.g. 25% rush surcharge) or <strong>Fixed Amount</strong> to add a flat fee (e.g. $75 rush fee). The rush charge appears as its own line on the quote.">
|
||||||
<i class="bi bi-question-circle"></i>
|
<i class="bi bi-question-circle"></i>
|
||||||
</a>
|
</a>
|
||||||
</h6>
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -628,7 +638,6 @@
|
|||||||
<label class="btn btn-outline-primary" for="rushChargeTypePercentage">
|
<label class="btn btn-outline-primary" for="rushChargeTypePercentage">
|
||||||
<i class="bi bi-percent"></i> Percentage
|
<i class="bi bi-percent"></i> Percentage
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<input type="radio" class="btn-check" name="rushChargeTypeRadio" id="rushChargeTypeFixed" value="FixedAmount" checked="@((Model.OperatingCosts?.RushChargeType) == "FixedAmount")">
|
<input type="radio" class="btn-check" name="rushChargeTypeRadio" id="rushChargeTypeFixed" value="FixedAmount" checked="@((Model.OperatingCosts?.RushChargeType) == "FixedAmount")">
|
||||||
<label class="btn btn-outline-primary" for="rushChargeTypeFixed">
|
<label class="btn btn-outline-primary" for="rushChargeTypeFixed">
|
||||||
<i class="bi bi-currency-dollar"></i> Fixed Amount
|
<i class="bi bi-currency-dollar"></i> Fixed Amount
|
||||||
@@ -647,7 +656,6 @@
|
|||||||
<small class="text-muted">Percentage of subtotal added for rush jobs</small>
|
<small class="text-muted">Percentage of subtotal added for rush jobs</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="rushChargeFixedInput" style="display: @((Model.OperatingCosts?.RushChargeType) == "FixedAmount" ? "block" : "none")">
|
<div id="rushChargeFixedInput" style="display: @((Model.OperatingCosts?.RushChargeType) == "FixedAmount" ? "block" : "none")">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="rushChargeFixedAmount" class="form-label">Rush Charge Amount</label>
|
<label for="rushChargeFixedAmount" class="form-label">Rush Charge Amount</label>
|
||||||
@@ -660,9 +668,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Part Complexity Multipliers -->
|
<!-- Part Complexity Multipliers -->
|
||||||
<div class="card mb-4 border-0 shadow-sm">
|
<div class="card mt-3 border-0 shadow-sm">
|
||||||
<div class="card-header bg-transparent fw-semibold">
|
<div class="card-header bg-transparent fw-semibold">
|
||||||
<i class="bi bi-layers text-primary me-1"></i> Part Complexity Multipliers
|
<i class="bi bi-layers text-primary me-1"></i> Part Complexity Multipliers
|
||||||
<a tabindex="0" class="help-icon" role="button"
|
<a tabindex="0" class="help-icon" role="button"
|
||||||
@@ -711,15 +721,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex justify-content-end mt-4">
|
<div class="d-flex justify-content-end mt-4 mb-2">
|
||||||
<button type="submit" class="btn btn-primary" id="btnSaveOperatingCosts">
|
<button type="submit" class="btn btn-primary" id="btnSaveOperatingCosts">
|
||||||
<i class="bi bi-save"></i> Save Operating Costs
|
<i class="bi bi-save"></i> Save Operating Costs
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Oven Cost Add/Edit Modal (outside all forms to avoid form interaction issues) -->
|
<!-- Oven Cost Add/Edit Modal (outside all forms to avoid form interaction issues) -->
|
||||||
<div class="modal fade" id="ovenModal" tabindex="-1" aria-labelledby="ovenModalTitle" aria-hidden="true">
|
<div class="modal fade" id="ovenModal" tabindex="-1" aria-labelledby="ovenModalTitle" aria-hidden="true">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
@@ -2949,76 +2958,6 @@
|
|||||||
loadOvenCosts();
|
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 ──────────────────────────────────────────────
|
// ── Quote PDF Template ──────────────────────────────────────────────
|
||||||
function syncColorPicker(hex) {
|
function syncColorPicker(hex) {
|
||||||
if (/^#[0-9A-Fa-f]{6}$/.test(hex)) {
|
if (/^#[0-9A-Fa-f]{6}$/.test(hex)) {
|
||||||
|
|||||||
@@ -106,6 +106,16 @@
|
|||||||
<input asp-for="Position" class="form-control" />
|
<input asp-for="Position" class="form-control" />
|
||||||
<span asp-validation-for="Position" class="text-danger"></span>
|
<span asp-validation-for="Position" class="text-danger"></span>
|
||||||
</div>
|
</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">
|
<div class="col-md-6">
|
||||||
<label asp-for="HireDate" class="form-label">Hire Date</label>
|
<label asp-for="HireDate" class="form-label">Hire Date</label>
|
||||||
<input asp-for="HireDate" class="form-control" type="date" />
|
<input asp-for="HireDate" class="form-control" type="date" />
|
||||||
|
|||||||
@@ -189,22 +189,6 @@
|
|||||||
<!-- Shop Management -->
|
<!-- Shop Management -->
|
||||||
<h2 class="h6 fw-semibold mb-2 text-muted text-uppercase" style="letter-spacing:.05em; font-size:.7rem;">Shop Management</h2>
|
<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="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="col-md-6">
|
||||||
<div class="card border-0 shadow-sm h-100">
|
<div class="card border-0 shadow-sm h-100">
|
||||||
<div class="card-body">
|
<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">
|
<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>
|
<span class="text-muted text-uppercase" style="font-size:.65rem; letter-spacing:.07em; font-weight:600;">Shop Management</span>
|
||||||
</div>
|
</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")"
|
<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="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")"
|
|
||||||
asp-controller="Help" asp-action="Equipment">
|
asp-controller="Help" asp-action="Equipment">
|
||||||
<i class="bi bi-tools"></i> Equipment & Maintenance
|
<i class="bi bi-tools"></i> Equipment & Maintenance
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1067,8 +1067,7 @@
|
|||||||
var hasEquipment = User.HasClaim("Permission", "ManageEquipment") || User.IsInRole("SuperAdmin");
|
var hasEquipment = User.HasClaim("Permission", "ManageEquipment") || User.IsInRole("SuperAdmin");
|
||||||
var hasMaintenance = User.HasClaim("Permission", "ManageMaintenance") || User.IsInRole("SuperAdmin");
|
var hasMaintenance = User.HasClaim("Permission", "ManageMaintenance") || User.IsInRole("SuperAdmin");
|
||||||
var hasFinance = _isAdminOrManager || User.HasClaim("Permission", "ManageFinance");
|
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 showOperations = hasCustomers || hasQuotes || hasInvoices || hasJobs || hasCalendar;
|
||||||
var showInventorySection = hasInventory || hasVendors;
|
var showInventorySection = hasInventory || hasVendors;
|
||||||
var showEquipmentSection = hasEquipment || hasMaintenance;
|
var showEquipmentSection = hasEquipment || hasMaintenance;
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
setupCsvImportForm('csvImportMaintenanceForm', 'csvMaintenanceFile', 'csvImportMaintenanceBtn', '/Tools/CsvImportMaintenance', 'csvMaintenanceResults');
|
setupCsvImportForm('csvImportMaintenanceForm', 'csvMaintenanceFile', 'csvImportMaintenanceBtn', '/Tools/CsvImportMaintenance', 'csvMaintenanceResults');
|
||||||
setupCsvImportForm('csvImportSettingsForm', 'csvSettingsFile', 'csvImportSettingsBtn', '/Tools/CsvImportCompanySettings', 'csvSettingsResults');
|
setupCsvImportForm('csvImportSettingsForm', 'csvSettingsFile', 'csvImportSettingsBtn', '/Tools/CsvImportCompanySettings', 'csvSettingsResults');
|
||||||
setupCsvImportForm('csvImportVendorsForm', 'csvVendorsFile', 'csvImportVendorsBtn', '/Tools/CsvImportVendors', 'csvVendorsResults');
|
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) {
|
function setupCsvImportForm(formId, fileInputId, submitBtnId, actionUrl, resultsId) {
|
||||||
|
|||||||
Reference in New Issue
Block a user