Remove ShopWorker entity and migrate worker identity to ApplicationUser

Removes the ShopWorker and ShopWorkerRoleCost entities, all related DTOs,
mappings, controllers, views, and import/export paths. Worker identity is
now handled entirely through ApplicationUser with per-user LaborCostPerHour.
ShopWorkerRoleCosts table remains in production pending manual data migration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 20:32:32 -04:00
parent 3b5511a703
commit 1a44133a63
43 changed files with 10989 additions and 1055 deletions
@@ -112,6 +112,7 @@ namespace PowderCoating.Application.DTOs.Company
// Labor Rates
public decimal StandardLaborRate { get; set; }
public decimal? LaborCostPerHour { get; set; }
public decimal AdditionalCoatLaborPercent { get; set; }
// Equipment Operating Costs
@@ -185,6 +186,10 @@ namespace PowderCoating.Application.DTOs.Company
[Display(Name = "Standard Labor Rate ($/hr)")]
public decimal StandardLaborRate { get; set; }
[Range(0, 10000, ErrorMessage = "Labor cost rate must be between 0 and 10,000")]
[Display(Name = "Shop Labor Cost Rate ($/hr)")]
public decimal? LaborCostPerHour { get; set; }
[Range(0, 100, ErrorMessage = "Additional coat labor percent must be between 0 and 100")]
[Display(Name = "Additional Coat Labor (%)")]
public decimal AdditionalCoatLaborPercent { get; set; } = 30m;
@@ -1,28 +0,0 @@
using CsvHelper.Configuration.Attributes;
namespace PowderCoating.Application.DTOs.Import;
/// <summary>
/// DTO for importing shop workers from CSV files.
/// Valid Role values: GeneralLabor, Sandblaster, Coater, Masker, QualityControl, OvenOperator, Supervisor, Maintenance
/// </summary>
public class ShopWorkerImportDto
{
[Name("Name")]
public string Name { get; set; } = string.Empty;
[Name("Role")]
public string Role { get; set; } = "GeneralLabor";
[Name("Phone")]
public string? Phone { get; set; }
[Name("Email")]
public string? Email { get; set; }
[Name("IsActive")]
public bool? IsActive { get; set; }
[Name("Notes")]
public string? Notes { get; set; }
}
@@ -1,27 +0,0 @@
using System.ComponentModel.DataAnnotations;
using PowderCoating.Core.Enums;
namespace PowderCoating.Application.DTOs.ShopWorker;
public class CreateShopWorkerDto
{
[Required(ErrorMessage = "Worker name is required")]
[StringLength(100, ErrorMessage = "Name cannot exceed 100 characters")]
public string Name { get; set; } = string.Empty;
[Required(ErrorMessage = "Role is required")]
public ShopWorkerRole Role { get; set; } = ShopWorkerRole.GeneralLabor;
[Phone(ErrorMessage = "Invalid phone number format")]
[StringLength(20, ErrorMessage = "Phone cannot exceed 20 characters")]
public string? Phone { get; set; }
[EmailAddress(ErrorMessage = "Invalid email address format")]
[StringLength(100, ErrorMessage = "Email cannot exceed 100 characters")]
public string? Email { get; set; }
public bool IsActive { get; set; } = true;
[StringLength(500, ErrorMessage = "Notes cannot exceed 500 characters")]
public string? Notes { get; set; }
}
@@ -1,16 +0,0 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Application.DTOs.ShopWorker;
public class ShopWorkerDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public ShopWorkerRole Role { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public bool IsActive { get; set; }
public string? Notes { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
}
@@ -1,29 +0,0 @@
using System.ComponentModel.DataAnnotations;
using PowderCoating.Core.Enums;
namespace PowderCoating.Application.DTOs.ShopWorker;
public class UpdateShopWorkerDto
{
public int Id { get; set; }
[Required(ErrorMessage = "Worker name is required")]
[StringLength(100, ErrorMessage = "Name cannot exceed 100 characters")]
public string Name { get; set; } = string.Empty;
[Required(ErrorMessage = "Role is required")]
public ShopWorkerRole Role { get; set; }
[Phone(ErrorMessage = "Invalid phone number format")]
[StringLength(20, ErrorMessage = "Phone cannot exceed 20 characters")]
public string? Phone { get; set; }
[EmailAddress(ErrorMessage = "Invalid email address format")]
[StringLength(100, ErrorMessage = "Email cannot exceed 100 characters")]
public string? Email { get; set; }
public bool IsActive { get; set; }
[StringLength(500, ErrorMessage = "Notes cannot exceed 500 characters")]
public string? Notes { get; set; }
}
@@ -217,6 +217,10 @@ public class UpdateCompanyUserDto
[Display(Name = "Active")]
public bool IsActive { get; set; }
[Range(0, 10000, ErrorMessage = "Labor cost rate must be between 0 and 10,000")]
[Display(Name = "Labor Cost Rate ($/hr)")]
public decimal? LaborCostPerHour { get; set; }
[Required(ErrorMessage = "Hire date is required")]
[Display(Name = "Hire Date")]
public DateTime HireDate { get; set; }
@@ -136,18 +136,7 @@ public interface ICsvImportService
/// </summary>
Task<CsvImportResultDto> ImportVendorsAsync(Stream csvStream, int companyId);
/// <summary>
/// Generate a CSV template file for shop worker imports.
/// </summary>
byte[] GenerateShopWorkerTemplate();
/// <summary>
/// Import shop workers from a CSV stream.
/// Updates existing workers matched by Name; creates new ones otherwise.
/// </summary>
Task<CsvImportResultDto> ImportShopWorkersAsync(Stream csvStream, int companyId);
/// <summary>
/// <summary>
/// Generate a CSV template file for prep service imports.
/// </summary>
byte[] GeneratePrepServiceTemplate();
@@ -73,7 +73,7 @@ public class JobProfile : Profile
// JobTimeEntry → JobTimeEntryDto
CreateMap<JobTimeEntry, JobTimeEntryDto>()
.ForMember(dest => dest.WorkerName, opt => opt.MapFrom(src =>
src.UserDisplayName ?? (src.Worker != null ? src.Worker.Name : string.Empty)));
src.UserDisplayName ?? string.Empty));
// CreateJobDto to Job
CreateMap<CreateJobDto, Job>()
@@ -1,23 +0,0 @@
using AutoMapper;
using PowderCoating.Application.DTOs.ShopWorker;
using PowderCoating.Core.Entities;
namespace PowderCoating.Application.Mappings;
public class ShopWorkerProfile : Profile
{
public ShopWorkerProfile()
{
// Entity to DTO
CreateMap<ShopWorker, ShopWorkerDto>();
// DTO to Entity
CreateMap<CreateShopWorkerDto, ShopWorker>();
CreateMap<UpdateShopWorkerDto, ShopWorker>();
// Reverse mappings
CreateMap<ShopWorkerDto, ShopWorker>();
CreateMap<ShopWorker, CreateShopWorkerDto>();
CreateMap<ShopWorker, UpdateShopWorkerDto>();
}
}
@@ -58,7 +58,14 @@ public class ApplicationUser : IdentityUser
public string? SidebarColor { get; set; } = "ocean";
public string? Notes { get; set; }
/// <summary>
/// Per-worker labor cost per hour used for job costing profit/margin calculations.
/// Overrides the company-level LaborCostPerHour when set.
/// Leave null to use the company default.
/// </summary>
public decimal? LaborCostPerHour { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
public DateTime? LastLoginDate { get; set; }
+1 -2
View File
@@ -141,8 +141,7 @@ public class Company : BaseEntity
public virtual ICollection<Quote> Quotes { get; set; } = new List<Quote>();
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
public virtual ICollection<Vendor> Vendors { get; set; } = new List<Vendor>();
public virtual ICollection<ShopWorker> ShopWorkers { get; set; } = new List<ShopWorker>();
public virtual ICollection<PricingTier> PricingTiers { get; set; } = new List<PricingTier>();
public virtual ICollection<PricingTier> PricingTiers { get; set; } = new List<PricingTier>();
public virtual CompanyOperatingCosts? OperatingCosts { get; set; }
public virtual CompanyPreferences? Preferences { get; set; }
}
@@ -13,6 +13,14 @@ namespace PowderCoating.Core.Entities
[Range(0, 10000)]
public decimal StandardLaborRate { get; set; }
/// <summary>
/// Actual labor cost per hour (wages + burden) used exclusively for internal job costing and profit/margin display.
/// This is NOT the billing rate — it should reflect what you actually pay workers.
/// When null, the costing engine defaults to 20% of StandardLaborRate.
/// </summary>
[Range(0, 10000)]
public decimal? LaborCostPerHour { get; set; }
// Additional Coat Labor Percentage (percentage of base labor for each additional coat beyond the first)
[Range(0, 100)]
public decimal AdditionalCoatLaborPercent { get; set; } = 30m;
@@ -3,7 +3,6 @@ namespace PowderCoating.Core.Entities;
public class JobTimeEntry : BaseEntity
{
public int JobId { get; set; }
public int? ShopWorkerId { get; set; } // legacy — kept for entries created before user migration
public string? UserId { get; set; } // FK to AspNetUsers
public string? UserDisplayName { get; set; } // snapshot of worker name at entry creation time
public DateTime WorkDate { get; set; }
@@ -13,5 +12,4 @@ public class JobTimeEntry : BaseEntity
// Navigation
public virtual Job Job { get; set; } = null!;
public virtual ShopWorker? Worker { get; set; } // nullable — only populated for legacy entries
}
@@ -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; }
}
-11
View File
@@ -78,17 +78,6 @@ public enum EquipmentStatus
Retired = 4
}
public enum ShopWorkerRole
{
GeneralLabor = 0,
Sandblaster = 1,
Coater = 2,
Masker = 3,
QualityControl = 4,
OvenOperator = 5,
Supervisor = 6,
Maintenance = 7
}
public enum JobPhotoType
{
@@ -54,9 +54,7 @@ public interface IUnitOfWork : IDisposable
IRepository<AppointmentStatusLookup> AppointmentStatusLookups { get; }
IRepository<AppointmentTypeLookup> AppointmentTypeLookups { get; }
IRepository<PrepService> PrepServices { get; }
IRepository<ShopWorker> ShopWorkers { get; }
IRepository<ShopWorkerRoleCost> ShopWorkerRoleCosts { get; }
IRepository<ReworkRecord> ReworkRecords { get; }
IRepository<ReworkRecord> ReworkRecords { get; }
IRepository<Refund> Refunds { get; }
IRepository<CreditMemo> CreditMemos { get; }
IRepository<CreditMemoApplication> CreditMemoApplications { get; }
@@ -205,11 +205,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
public DbSet<MaintenanceRecord> MaintenanceRecords { get; set; }
/// <summary>Supplier/vendor records used by Purchasing and Accounts Payable; tenant-filtered with soft delete.</summary>
public DbSet<Vendor> Vendors { get; set; }
/// <summary>Shop worker profiles with role assignments; tenant-filtered with soft delete.</summary>
public DbSet<ShopWorker> ShopWorkers { get; set; }
/// <summary>Per-role labour cost rates used in pricing calculations; unique index on (CompanyId, Role).</summary>
public DbSet<ShopWorkerRoleCost> ShopWorkerRoleCosts { get; set; }
/// <summary>Rework records tracking quality failures and remediation work against a job; tenant-filtered with soft delete.</summary>
/// <summary>Rework records tracking quality failures and remediation work against a job; tenant-filtered with soft delete.</summary>
public DbSet<ReworkRecord> ReworkRecords { get; set; }
/// <summary>Customer refund records; tenant-filtered with soft delete.</summary>
public DbSet<Refund> Refunds { get; set; }
@@ -530,11 +526,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<JobStatusHistory>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<ShopWorker>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<ShopWorkerRoleCost>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<Refund>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
@@ -1314,12 +1306,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
.HasForeignKey(m => m.PerformedById)
.OnDelete(DeleteBehavior.SetNull);
// ShopWorker relationships
modelBuilder.Entity<ShopWorker>()
.HasOne<Company>()
.WithMany(c => c.ShopWorkers)
.HasForeignKey(e => e.CompanyId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Job>()
.HasOne(j => j.AssignedUser)
@@ -1393,10 +1380,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
modelBuilder.Entity<PricingTier>()
.HasIndex(p => p.CompanyId);
modelBuilder.Entity<ShopWorker>()
.HasIndex(w => w.CompanyId);
modelBuilder.Entity<CatalogCategory>()
modelBuilder.Entity<CatalogCategory>()
.HasIndex(c => c.CompanyId);
modelBuilder.Entity<CatalogCategory>()
@@ -1431,12 +1415,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
.IsUnique()
.HasDatabaseName("IX_Jobs_CompanyId_JobNumber");
modelBuilder.Entity<ShopWorkerRoleCost>()
.HasIndex(r => new { r.CompanyId, r.Role })
.IsUnique()
.HasDatabaseName("IX_ShopWorkerRoleCosts_CompanyId_Role");
modelBuilder.Entity<Job>()
modelBuilder.Entity<Job>()
.Property(j => j.ShopAccessCode)
.HasDefaultValueSql("NEWID()");
@@ -21,7 +21,7 @@ public class AuditInterceptor : SaveChangesInterceptor
private static readonly HashSet<string> AuditedTypes = new(StringComparer.Ordinal)
{
nameof(Customer), nameof(Job), nameof(Quote), nameof(Equipment),
nameof(MaintenanceRecord), nameof(Vendor), nameof(ShopWorker),
nameof(MaintenanceRecord), nameof(Vendor),
nameof(InventoryItem), nameof(Company),
// Financial entities
nameof(Invoice), nameof(Payment), nameof(Bill), nameof(BillPayment),
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,81 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddLaborCostPerHour : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "LaborCostPerHour",
table: "CompanyOperatingCosts",
type: "decimal(18,2)",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "LaborCostPerHour",
table: "AspNetUsers",
type: "decimal(18,2)",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3131));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3137));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3138));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "LaborCostPerHour",
table: "CompanyOperatingCosts");
migrationBuilder.DropColumn(
name: "LaborCostPerHour",
table: "AspNetUsers");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(845));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(850));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(852));
}
}
}
@@ -556,6 +556,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("IsBanned")
.HasColumnType("bit");
b.Property<decimal?>("LaborCostPerHour")
.HasColumnType("decimal(18,2)");
b.Property<DateTime?>("LastLoginDate")
.HasColumnType("datetime2");
@@ -2075,6 +2078,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<decimal?>("LaborCostPerHour")
.HasColumnType("decimal(18,2)");
b.Property<int>("MonthlyBillableHours")
.HasColumnType("int");
@@ -6714,7 +6720,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
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",
DiscountPercent = 0m,
IsActive = true,
@@ -6725,7 +6731,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
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",
DiscountPercent = 5m,
IsActive = true,
@@ -6736,7 +6742,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
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",
DiscountPercent = 10m,
IsActive = true,
@@ -171,7 +171,6 @@ public class JobRepository : Repository<Job>, IJobRepository
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
.Include(j => j.TimeEntries.Where(t => !t.IsDeleted))
.ThenInclude(t => t.Worker)
.AsNoTracking()
.FirstOrDefaultAsync();
}
@@ -81,7 +81,6 @@ public class UnitOfWork : IUnitOfWork
private IRepository<AppointmentStatusLookup>? _appointmentStatusLookups;
private IRepository<AppointmentTypeLookup>? _appointmentTypeLookups;
private IRepository<PrepService>? _prepServices;
private IRepository<ShopWorker>? _shopWorkers;
// Appointments
private IRepository<Appointment>? _appointments;
@@ -350,16 +349,7 @@ public class UnitOfWork : IUnitOfWork
public IRepository<PrepService> PrepServices =>
_prepServices ??= new Repository<PrepService>(_context);
/// <summary>Repository for <see cref="ShopWorker"/> profiles with role assignments; tenant-filtered with soft delete.</summary>
public IRepository<ShopWorker> ShopWorkers =>
_shopWorkers ??= new Repository<ShopWorker>(_context);
/// <summary>Repository for <see cref="ShopWorkerRoleCost"/> per-role labour cost rates; unique on (CompanyId, Role).</summary>
private IRepository<ShopWorkerRoleCost>? _shopWorkerRoleCosts;
public IRepository<ShopWorkerRoleCost> ShopWorkerRoleCosts =>
_shopWorkerRoleCosts ??= new Repository<ShopWorkerRoleCost>(_context);
/// <summary>Repository for <see cref="ReworkRecord"/> quality-failure and remediation records; tenant-filtered with soft delete.</summary>
/// <summary>Repository for <see cref="ReworkRecord"/> quality-failure and remediation records; tenant-filtered with soft delete.</summary>
private IRepository<ReworkRecord>? _reworkRecords;
public IRepository<ReworkRecord> ReworkRecords =>
_reworkRecords ??= new Repository<ReworkRecord>(_context);
@@ -73,7 +73,6 @@ public class CompanyDataPurgeService : ICompanyDataPurgeService
await _context.NotificationTemplates.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.Announcements.Where(x => x.TargetCompanyId == companyId).ExecuteDeleteAsync();
await _context.BugReports.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.ShopWorkers.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.PrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
// ── Tier 4: Company configs and lookup tables ─────────────────────────
@@ -137,7 +136,6 @@ public class CompanyDataPurgeService : ICompanyDataPurgeService
await _context.PurchaseOrderItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.AiItemPredictions.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.PowderUsageLogs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.ShopWorkerRoleCosts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.OvenBatches.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.Refunds.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.CreditMemos.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
@@ -160,7 +158,6 @@ public class CompanyDataPurgeService : ICompanyDataPurgeService
await _context.OvenCosts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.Accounts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.NotificationLogs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.ShopWorkers.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.PrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.CatalogCategories.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.InventoryCategoryLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
@@ -1,4 +1,4 @@
using System.Globalization;
using System.Globalization;
using System.Text;
using CsvHelper;
using CsvHelper.Configuration;
@@ -2164,168 +2164,6 @@ public class CsvImportService : ICsvImportService
}
#endregion
#region Shop Worker Import
/// <summary>
/// Generates a downloadable CSV template with two example shop worker rows covering different roles.
/// Two rows help users see how Role values (Coater, Sandblaster, etc.) are expressed and remind
/// them that Role is optional — the importer will default to GeneralLabor when it is omitted.
/// </summary>
public byte[] GenerateShopWorkerTemplate()
{
using var memoryStream = new MemoryStream();
using var writer = new StreamWriter(memoryStream);
using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture));
csv.WriteHeader<ShopWorkerImportDto>();
csv.NextRecord();
csv.WriteRecord(new ShopWorkerImportDto
{
Name = "John Doe",
Role = "Coater",
Phone = "555-1234",
Email = "johndoe@example.com",
IsActive = true,
Notes = "Experienced powder coater"
});
csv.NextRecord();
csv.WriteRecord(new ShopWorkerImportDto
{
Name = "Jane Smith",
Role = "Sandblaster",
Phone = "555-5678",
Email = "janesmith@example.com",
IsActive = true,
Notes = ""
});
csv.NextRecord();
writer.Flush();
return memoryStream.ToArray();
}
/// <summary>
/// Imports shop workers from a CSV stream using an upsert strategy keyed on worker Name.
/// Like vendor import, this is intentionally an upsert rather than insert-only so that a
/// company can re-import their HR list to update phone/email/role details without worrying
/// about creating duplicates. Role is parsed case-insensitively with spaces stripped so that
/// "General Labor" and "GeneralLabor" are both accepted; an unrecognised role falls back to
/// GeneralLabor with a warning rather than failing the row.
/// </summary>
/// <param name="csvStream">Readable stream of CSV data (header row required).</param>
/// <param name="companyId">Tenant company that will own newly inserted worker records.</param>
public async Task<CsvImportResultDto> ImportShopWorkersAsync(Stream csvStream, int companyId)
{
var result = new CsvImportResultDto();
var rowNumber = 0;
try
{
using var reader = new StreamReader(csvStream);
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
{
HeaderValidated = null,
MissingFieldFound = null
});
var records = csv.GetRecords<ShopWorkerImportDto>().ToList();
result.TotalRows = records.Count;
_logger.LogInformation("Starting import of {Count} shop workers for company {CompanyId}", records.Count, companyId);
// Load existing workers for upsert matching by name
var existingWorkers = await _unitOfWork.ShopWorkers.GetAllAsync();
var workerDict = existingWorkers
.Where(w => !string.IsNullOrEmpty(w.Name))
.GroupBy(w => w.Name.Trim().ToUpperInvariant())
.ToDictionary(g => g.Key, g => g.First());
foreach (var record in records)
{
rowNumber++;
try
{
if (string.IsNullOrWhiteSpace(record.Name))
{
result.Errors.Add($"Row {rowNumber}: Name is required.");
result.ErrorCount++;
continue;
}
// Parse role
ShopWorkerRole role = ShopWorkerRole.GeneralLabor;
if (!string.IsNullOrEmpty(record.Role))
{
if (!Enum.TryParse<ShopWorkerRole>(record.Role.Replace(" ", ""), true, out role))
{
result.Warnings.Add($"Row {rowNumber}: Role '{record.Role}' not recognized. Valid values: GeneralLabor, Sandblaster, Coater, Masker, QualityControl, OvenOperator, Supervisor, Maintenance. Using 'GeneralLabor'.");
role = ShopWorkerRole.GeneralLabor;
}
}
var key = record.Name.Trim().ToUpperInvariant();
var now = DateTime.UtcNow;
if (workerDict.TryGetValue(key, out var existing))
{
// Update
existing.Role = role;
existing.Phone = record.Phone ?? existing.Phone;
existing.Email = record.Email ?? existing.Email;
if (record.IsActive.HasValue) existing.IsActive = record.IsActive.Value;
existing.Notes = record.Notes ?? existing.Notes;
existing.UpdatedAt = now;
await _unitOfWork.CompleteAsync();
result.Warnings.Add($"Row {rowNumber}: Updated existing shop worker '{record.Name}'.");
result.SuccessCount++;
}
else
{
var worker = new Core.Entities.ShopWorker
{
CompanyId = companyId,
Name = record.Name.Trim(),
Role = role,
Phone = record.Phone,
Email = record.Email,
IsActive = record.IsActive ?? true,
Notes = record.Notes,
CreatedAt = now,
UpdatedAt = now
};
await _unitOfWork.ShopWorkers.AddAsync(worker);
await _unitOfWork.CompleteAsync();
result.SuccessCount++;
}
}
catch (Exception ex)
{
result.Errors.Add($"Row {rowNumber}: Database error - {ex.Message}");
result.ErrorCount++;
_logger.LogError(ex, "Error saving shop worker at row {RowNumber}", rowNumber);
}
}
_logger.LogInformation("Shop worker import completed: {SuccessCount} succeeded, {ErrorCount} failed", result.SuccessCount, result.ErrorCount);
result.Success = result.SuccessCount > 0;
}
catch (Exception ex)
{
result.Errors.Add($"Fatal error: {ex.Message}");
result.Success = false;
_logger.LogError(ex, "Fatal error importing shop workers");
}
return result;
}
#endregion
#region Prep Service Import
/// <summary>
@@ -133,7 +133,6 @@ public class AccountDataExportController : Controller
case "Inventory": await AddInventorySheet(package, companyId, headerColor); break;
case "Equipment": await AddEquipmentSheet(package, companyId, headerColor); break;
case "Vendors": await AddVendorsSheet(package, companyId, headerColor); break;
case "ShopWorkers": await AddShopWorkersSheet(package, companyId, headerColor); break;
case "Users": await AddUsersSheet(package, companyId, headerColor); break;
}
}
@@ -182,7 +181,6 @@ public class AccountDataExportController : Controller
case "Inventory": WriteCsvEntry(zip, "Inventory.csv", await BuildInventoryCsv(companyId)); break;
case "Equipment": WriteCsvEntry(zip, "Equipment.csv", await BuildEquipmentCsv(companyId)); break;
case "Vendors": WriteCsvEntry(zip, "Vendors.csv", await BuildVendorsCsv(companyId)); break;
case "ShopWorkers": WriteCsvEntry(zip, "ShopWorkers.csv", await BuildShopWorkersCsv(companyId)); break;
case "Users": WriteCsvEntry(zip, "Users.csv", await BuildUsersCsv(companyId)); break;
}
}
@@ -268,12 +266,6 @@ public class AccountDataExportController : Controller
.Where(s => s.CompanyId == companyId && !s.IsDeleted)
.OrderBy(s => s.CompanyName).ToListAsync();
/// <summary>Fetches all non-deleted shop workers for the company.</summary>
private Task<List<ShopWorker>> FetchShopWorkersAsync(int companyId) =>
_db.ShopWorkers.AsNoTracking().IgnoreQueryFilters()
.Where(w => w.CompanyId == companyId && !w.IsDeleted)
.OrderBy(w => w.Name).ToListAsync();
/// <summary>
/// Fetches all users for the company. <c>IsDeleted</c> is intentionally omitted because
/// Identity users use <c>IsActive = false</c> for soft-deletion, not the base-entity flag.
@@ -462,23 +454,6 @@ public class AccountDataExportController : Controller
AutoFit(ws, headers.Length);
}
private async Task AddShopWorkersSheet(ExcelPackage pkg, int companyId, Color hdr)
{
var data = await FetchShopWorkersAsync(companyId);
var ws = pkg.Workbook.Worksheets.Add("Shop Workers");
var headers = new[] { "ID", "Name", "Role", "Phone", "Email", "Active", "Notes" };
WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++)
{
var r = i + 2; var w = data[i];
ws.Cells[r, 1].Value = w.Id; ws.Cells[r, 2].Value = w.Name;
ws.Cells[r, 3].Value = w.Role.ToString(); ws.Cells[r, 4].Value = w.Phone;
ws.Cells[r, 5].Value = w.Email; ws.Cells[r, 6].Value = w.IsActive ? "Yes" : "No";
ws.Cells[r, 7].Value = w.Notes;
}
AutoFit(ws, headers.Length);
}
/// <summary>
/// Adds a "Users" worksheet. All users (active and inactive) are included because Identity
/// uses <c>IsActive = false</c> for soft-deletion; <c>IsDeleted</c> is not applicable here.
@@ -611,17 +586,6 @@ public class AccountDataExportController : Controller
return sb.ToString();
}
/// <summary>Column names match <c>ShopWorkerImportDto</c> exactly so the file can be re-imported.</summary>
private async Task<string> BuildShopWorkersCsv(int companyId)
{
var data = await FetchShopWorkersAsync(companyId);
var sb = new StringBuilder();
sb.AppendLine("Name,Role,Phone,Email,IsActive,Notes");
foreach (var w in data)
sb.AppendLine($"{CsvEscape(w.Name)},{w.Role},{CsvEscape(w.Phone)},{CsvEscape(w.Email)},{w.IsActive.ToString().ToLower()},{CsvEscape(w.Notes)}");
return sb.ToString();
}
/// <summary>
/// All users (active and inactive) are exported for completeness and compliance — mirrors
/// the reasoning in <see cref="AddUsersSheet"/> and <see cref="FetchUsersAsync"/>.
@@ -675,13 +639,13 @@ public class AccountDataExportController : Controller
/// <summary>
/// Returns the subset of selected sheet names reordered into the canonical export sequence
/// (Customers → Jobs → Quotes → Invoices → Inventory → Equipment → Vendors → ShopWorkers → Users).
/// (Customers → Jobs → Quotes → Invoices → Inventory → Equipment → Vendors → Users).
/// Guarantees consistent file layout regardless of the order check-boxes were ticked on the form.
/// Sheet names not in the canonical list are silently dropped.
/// </summary>
private static string[] OrderSheets(string[] sheets)
{
var order = new[] { "Customers", "Jobs", "Quotes", "Invoices", "Inventory", "Equipment", "Vendors", "ShopWorkers", "Users" };
var order = new[] { "Customers", "Jobs", "Quotes", "Invoices", "Inventory", "Equipment", "Vendors", "Users" };
return order.Where(sheets.Contains).ToArray();
}
@@ -756,7 +756,6 @@ public class CompanySettingsController : Controller
var costs = company.OperatingCosts;
var ovens = (await _unitOfWork.OvenCosts.FindAsync(o => o.IsActive)).OrderBy(o => o.DisplayOrder).ToList();
var workers = (await _unitOfWork.ShopWorkers.FindAsync(w => w.IsActive)).ToList();
var coatingCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsCoating)).ToList();
var sb = new System.Text.StringBuilder();
@@ -783,8 +782,7 @@ public class CompanySettingsController : Controller
ShopCapabilityTier.Large => "high-volume",
_ => "small"
};
sb.AppendLine($"We are a {tierLabel} operation" +
(workers.Count > 0 ? $" with {workers.Count} active shop worker{(workers.Count == 1 ? "" : "s")}." : "."));
sb.AppendLine($"We are a {tierLabel} operation.");
}
// Ovens
@@ -827,32 +825,6 @@ public class CompanySettingsController : Controller
sb.AppendLine($"Powder categories we stock: {string.Join(", ", catNames)}.");
}
// Worker roles
if (workers.Any())
{
var roles = workers
.Select(w => w.Role)
.Distinct()
.Select(r => r switch
{
ShopWorkerRole.Sandblaster => "sandblasting",
ShopWorkerRole.Coater => "powder coating",
ShopWorkerRole.Masker => "masking",
ShopWorkerRole.QualityControl => "quality control",
ShopWorkerRole.OvenOperator => "oven operation",
ShopWorkerRole.Supervisor => "supervision",
ShopWorkerRole.Maintenance => "equipment maintenance",
_ => "general labor"
})
.Distinct()
.ToList();
if (roles.Count > 1)
{
sb.AppendLine();
sb.AppendLine($"Staff specialties on hand: {string.Join(", ", roles)}.");
}
}
// Rates hint
if (costs != null && costs.StandardLaborRate > 0)
{
@@ -2719,79 +2691,6 @@ public class CompanySettingsController : Controller
// ── Role-Based Labor Rates ────────────────────────────────────────────────
/// <summary>
/// Returns the per-role hourly labor rates configured for the current company, keyed by
/// <see cref="PowderCoating.Core.Enums.ShopWorkerRole"/> integer value. An empty list is returned
/// (rather than a 404) when no rates have been configured yet, so the UI can render the rate grid
/// without special-casing an empty state. The global multi-tenant filter on
/// <c>ShopWorkerRoleCosts</c> ensures only this company's rates are returned.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetRoleCosts()
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) return Json(new List<object>());
var rates = await _unitOfWork.ShopWorkerRoleCosts.FindAsync(r => r.CompanyId == companyId.Value);
var result = rates.Select(r => new { role = (int)r.Role, hourlyRate = r.HourlyRate }).ToList();
return Json(result);
}
/// <summary>
/// Upserts the per-role hourly labor rates for the current company. The operation handles three cases
/// per rate in a single pass: (1) rate cleared (≤ 0) — soft-delete the existing record; (2) rate set
/// but no existing record — insert new; (3) rate changed — update existing. This avoids full
/// table replace semantics that could cause audit log noise or trigger unintended EF change-tracking.
/// These rates are used by the pricing calculator when <c>UseRoleBasedLaborRates</c> is enabled in
/// <c>CompanyOperatingCosts</c>.
/// </summary>
[HttpPost]
public async Task<IActionResult> SaveRoleCosts([FromBody] List<SaveRoleCostDto> rates)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) return Json(new { success = false, message = "No company found." });
var existing = (await _unitOfWork.ShopWorkerRoleCosts.FindAsync(r => r.CompanyId == companyId.Value)).ToList();
foreach (var dto in rates)
{
var record = existing.FirstOrDefault(r => (int)r.Role == dto.Role);
if (dto.HourlyRate <= 0)
{
// Remove rate if cleared
if (record != null)
await _unitOfWork.ShopWorkerRoleCosts.SoftDeleteAsync(record.Id);
}
else if (record == null)
{
await _unitOfWork.ShopWorkerRoleCosts.AddAsync(new PowderCoating.Core.Entities.ShopWorkerRoleCost
{
CompanyId = companyId.Value,
Role = (PowderCoating.Core.Enums.ShopWorkerRole)dto.Role,
HourlyRate = dto.HourlyRate,
CreatedAt = DateTime.UtcNow
});
}
else
{
record.HourlyRate = dto.HourlyRate;
record.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.ShopWorkerRoleCosts.UpdateAsync(record);
}
}
await _unitOfWork.CompleteAsync();
return Json(new { success = true });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving role costs");
return Json(new { success = false, message = "An error occurred saving role rates." });
}
}
// ─── Stripe Connect ───────────────────────────────────────────────────────
/// <summary>
@@ -3055,7 +2954,6 @@ public class CompanySettingsController : Controller
}
public record SaveTemplateJsonRequest(int Id, string? Subject, string? Body);
public record SaveRoleCostDto(int Role, decimal HourlyRate);
public record SaveOnlinePaymentSettingsDto(
OnlinePaymentSurchargeType SurchargeType,
decimal SurchargeValue,
@@ -226,11 +226,9 @@ public class CompanyUsersController : Controller
/// Creates a new company user, enforcing the subscription user-count limit and a whitelist
/// of valid <c>CompanyRole</c> values (preventing callers from submitting a null role to
/// create a SuperAdmin-equivalent account). CompanyAdmin users automatically receive all
/// per-feature permissions unless a SuperAdmin is explicitly customising them. Workers
/// additionally get an auto-created <see cref="ShopWorker"/> record so they appear in job
/// assignment dropdowns without a separate onboarding step. A legacy ASP.NET Identity role
/// (Administrator / Manager / Employee / ReadOnly) is also assigned to satisfy policy
/// checks that still reference the role system.
/// per-feature permissions unless a SuperAdmin is explicitly customising them. A legacy
/// ASP.NET Identity role (Administrator / Manager / Employee / ReadOnly) is also assigned
/// to satisfy policy checks that still reference the role system.
/// </summary>
// POST: CompanyUsers/Create
[HttpPost]
@@ -351,27 +349,7 @@ public class CompanyUsersController : Controller
await _userManager.AddToRoleAsync(user, legacyRole);
// If Worker role, automatically create a ShopWorker record
if (model.CompanyRole == AppConstants.CompanyRoles.Worker)
{
var shopWorker = new ShopWorker
{
Name = user.FullName,
Email = user.Email,
Phone = user.PhoneNumber,
IsActive = true,
Notes = $"Auto-created from user account: {user.Email}",
Role = Core.Enums.ShopWorkerRole.GeneralLabor, // Default role
CompanyId = companyId!.Value
};
await _unitOfWork.ShopWorkers.AddAsync(shopWorker);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("ShopWorker record created for user {Email}", user.Email);
}
_logger.LogInformation("User {Email} created successfully by {Admin}",
_logger.LogInformation("User {Email} created successfully by {Admin}",
user.Email, User.Identity?.Name);
TempData["Success"] = $"User '{user.FullName}' created successfully.";
@@ -441,6 +419,7 @@ public class CompanyUsersController : Controller
CompanyRole = user.CompanyRole ?? AppConstants.CompanyRoles.Viewer,
Department = user.Department,
Position = user.Position,
LaborCostPerHour = user.LaborCostPerHour,
Phone = user.PhoneNumber,
IsActive = user.IsActive,
HireDate = user.HireDate,
@@ -479,11 +458,9 @@ public class CompanyUsersController : Controller
/// Saves changes to an existing company user. Validates company isolation and role whitelist
/// (same checks as <see cref="Edit(string, string)"/>). Prevents two dangerous deactivation
/// scenarios: a user deactivating themselves, and deactivating the last active CompanyAdmin
/// for a company (which would lock out the tenant). When the role changes to Worker and no
/// matching <see cref="ShopWorker"/> record exists, one is created automatically; if one
/// already exists, its name, email, and active status are kept in sync. Email changes are
/// applied via <c>SetEmailAsync</c> / <c>SetUserNameAsync</c> after the main update so
/// Identity's own normalisation logic runs correctly.
/// for a company (which would lock out the tenant). Email changes are applied via
/// <c>SetEmailAsync</c> / <c>SetUserNameAsync</c> after the main update so Identity's own
/// normalisation logic runs correctly.
/// </summary>
// POST: CompanyUsers/Edit/id
[HttpPost]
@@ -596,6 +573,7 @@ public class CompanyUsersController : Controller
user.CompanyRole = model.CompanyRole;
user.Department = model.Department;
user.Position = model.Position;
user.LaborCostPerHour = model.LaborCostPerHour;
user.PhoneNumber = model.Phone;
user.IsActive = model.IsActive;
user.HireDate = model.HireDate;
@@ -632,60 +610,7 @@ public class CompanyUsersController : Controller
user.Id, oldEmail, model.Email, User.Identity?.Name);
}
// If role changed to Worker, ensure ShopWorker record exists
if (model.CompanyRole == AppConstants.CompanyRoles.Worker)
{
// Search by oldEmail so we find the record even when the email just changed
var lookupEmail = emailChanged ? oldEmail : user.Email;
var existingShopWorker = (await _unitOfWork.ShopWorkers.FindAsync(
sw => sw.Email == lookupEmail && sw.CompanyId == user.CompanyId)).ToList();
if (!existingShopWorker.Any())
{
var shopWorker = new ShopWorker
{
Name = user.FullName,
Email = user.Email,
Phone = user.PhoneNumber,
IsActive = user.IsActive,
Notes = $"Auto-created from user account: {user.Email}",
Role = Core.Enums.ShopWorkerRole.GeneralLabor, // Default role
CompanyId = user.CompanyId
};
await _unitOfWork.ShopWorkers.AddAsync(shopWorker);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("ShopWorker record created for user {Email}", user.Email);
}
else
{
// Update existing ShopWorker to ensure it's active
var shopWorker = existingShopWorker.First();
var shopWorkerDirty = false;
if (!shopWorker.IsActive && user.IsActive)
{
shopWorker.IsActive = true;
shopWorkerDirty = true;
_logger.LogInformation("ShopWorker record reactivated for user {Email}", user.Email);
}
if (emailChanged && shopWorker.Email == oldEmail)
{
shopWorker.Email = user.Email;
shopWorkerDirty = true;
}
shopWorker.Name = user.FullName;
shopWorker.Phone = user.PhoneNumber;
if (shopWorkerDirty)
await _unitOfWork.CompleteAsync();
}
}
_logger.LogInformation("User {Email} updated successfully by {Admin}",
_logger.LogInformation("User {Email} updated successfully by {Admin}",
user.Email, User.Identity?.Name);
TempData["Success"] = "User updated successfully.";
@@ -122,7 +122,6 @@ public class DataExportController : Controller
case "Inventory": await AddInventorySheet(package, companyId, headerColor); break;
case "Equipment": await AddEquipmentSheet(package, companyId, headerColor); break;
case "Vendors": await AddVendorsSheet(package, companyId, headerColor); break;
case "ShopWorkers": await AddShopWorkersSheet(package, companyId, headerColor); break;
case "Users": await AddUsersSheet(package, companyId, headerColor); break;
}
}
@@ -172,7 +171,6 @@ public class DataExportController : Controller
case "Inventory": WriteCsvEntry(zip, "Inventory.csv", await BuildInventoryCsv(companyId)); break;
case "Equipment": WriteCsvEntry(zip, "Equipment.csv", await BuildEquipmentCsv(companyId)); break;
case "Vendors": WriteCsvEntry(zip, "Vendors.csv", await BuildVendorsCsv(companyId)); break;
case "ShopWorkers": WriteCsvEntry(zip, "ShopWorkers.csv", await BuildShopWorkersCsv(companyId)); break;
case "Users": WriteCsvEntry(zip, "Users.csv", await BuildUsersCsv(companyId)); break;
}
}
@@ -441,38 +439,6 @@ public class DataExportController : Controller
AutoFit(ws, headers.Length);
}
/// <summary>
/// Adds a "Shop Workers" worksheet with one row per non-deleted shop worker for the
/// specified company. <c>Role.ToString()</c> converts the enum to a string; the view
/// typically formats these with spaces (e.g. "QualityControl" → "Quality Control") but the
/// raw enum name is used here so the export value is round-trip parseable.
/// </summary>
private async Task AddShopWorkersSheet(ExcelPackage pkg, int companyId, Color hdr)
{
var data = await _db.ShopWorkers.AsNoTracking().IgnoreQueryFilters()
.Where(w => w.CompanyId == companyId && !w.IsDeleted)
.OrderBy(w => w.Name)
.ToListAsync();
var ws = pkg.Workbook.Worksheets.Add("Shop Workers");
var headers = new[] { "ID", "Name", "Role", "Phone", "Email", "Active", "Notes" };
WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++)
{
var r = i + 2;
var w = data[i];
ws.Cells[r, 1].Value = w.Id;
ws.Cells[r, 2].Value = w.Name;
ws.Cells[r, 3].Value = w.Role.ToString();
ws.Cells[r, 4].Value = w.Phone;
ws.Cells[r, 5].Value = w.Email;
ws.Cells[r, 6].Value = w.IsActive ? "Yes" : "No";
ws.Cells[r, 7].Value = w.Notes;
}
AutoFit(ws, headers.Length);
}
/// <summary>
/// Adds an "Invoices" worksheet with one row per non-deleted invoice for the specified company.
/// The customer navigation is eagerly loaded so the customer name can be rendered; when a
@@ -687,21 +653,6 @@ public class DataExportController : Controller
return sb.ToString();
}
/// <summary>
/// Builds the shop workers CSV string for the specified company, ordered alphabetically by name.
/// Column names match <see cref="ShopWorkerImportDto"/> exactly so the file can be re-imported.
/// </summary>
private async Task<string> BuildShopWorkersCsv(int companyId)
{
var data = await _db.ShopWorkers.AsNoTracking().IgnoreQueryFilters()
.Where(w => w.CompanyId == companyId && !w.IsDeleted).OrderBy(w => w.Name).ToListAsync();
var sb = new StringBuilder();
sb.AppendLine("Name,Role,Phone,Email,IsActive,Notes");
foreach (var w in data)
sb.AppendLine($"{CsvEscape(w.Name)},{w.Role},{CsvEscape(w.Phone)},{CsvEscape(w.Email)},{w.IsActive.ToString().ToLower()},{CsvEscape(w.Notes)}");
return sb.ToString();
}
/// <summary>
/// Builds the users CSV string for the specified company, ordered by last name.
/// Like <see cref="AddUsersSheet"/>, the <c>IsDeleted</c> filter is intentionally omitted
@@ -769,7 +720,7 @@ public class DataExportController : Controller
/// <param name="sheets">Raw sheet names from the form POST.</param>
private static string[] OrderSheets(string[] sheets)
{
var order = new[] { "Customers", "Jobs", "Quotes", "Invoices", "Inventory", "Equipment", "Vendors", "ShopWorkers", "Users" };
var order = new[] { "Customers", "Jobs", "Quotes", "Invoices", "Inventory", "Equipment", "Vendors", "Users" };
return order.Where(sheets.Contains).ToArray();
}
@@ -175,7 +175,6 @@ public class DataPurgeController : Controller
stats.Add(await Stat("Equipment", "Equipment", "bi-tools", "Inventory & Ops", _db.Equipment.Where(e => e.IsDeleted)));
stats.Add(await Stat("MaintenanceRecords","Maintenance Records", "bi-wrench", "Inventory & Ops", _db.MaintenanceRecords.Where(e => e.IsDeleted)));
stats.Add(await Stat("Vendors", "Vendors", "bi-truck", "Inventory & Ops", _db.Vendors.Where(e => e.IsDeleted)));
stats.Add(await Stat("ShopWorkers", "Shop Workers", "bi-person-badge","Inventory & Ops", _db.ShopWorkers.Where(e => e.IsDeleted)));
return stats;
}
@@ -204,7 +203,6 @@ public class DataPurgeController : Controller
"Equipment" => await QueryCount(_db.Equipment, cutoff),
"MaintenanceRecords" => await QueryCount(_db.MaintenanceRecords, cutoff),
"Vendors" => await QueryCount(_db.Vendors, cutoff),
"ShopWorkers" => await QueryCount(_db.ShopWorkers, cutoff),
_ => (0, null)
};
}
@@ -324,11 +322,6 @@ public class DataPurgeController : Controller
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
break;
case "ShopWorkers":
count = await _db.ShopWorkers.IgnoreQueryFilters()
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
break;
default:
return 0;
}
@@ -38,14 +38,6 @@ namespace PowderCoating.Web.Controllers
return View();
}
/// <summary>
/// Serves the Shop Workers help article describing roles, assignment to jobs, and maintenance tasks.
/// </summary>
public IActionResult ShopWorkers()
{
return View();
}
/// <summary>
/// Serves the Equipment help article explaining the equipment status lifecycle and maintenance scheduling.
/// </summary>
@@ -3368,8 +3368,7 @@ public class JobsController : Controller
public async Task<IActionResult> GetTimeEntries(int jobId)
{
var entries = await _unitOfWork.JobTimeEntries.FindAsync(
e => e.JobId == jobId, false,
e => e.Worker); // Worker nav loaded for display of legacy entries that pre-date user migration
e => e.JobId == jobId, false);
var dtos = _mapper.Map<List<JobTimeEntryDto>>(entries.OrderByDescending(e => e.WorkDate).ToList());
return Json(dtos);
}
@@ -3823,15 +3822,24 @@ public class JobsController : Controller
// Operating costs for fallback labor rate and oven rate
var opCosts = (await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == companyId)).FirstOrDefault();
var fallbackLaborRate = opCosts?.StandardLaborRate ?? 0m;
var effectiveOvenMinutes = (opCosts?.DefaultOvenCycleMinutes > 0 ? (int?)opCosts!.DefaultOvenCycleMinutes : null) ?? 45;
var defaultOvenCycleHours = effectiveOvenMinutes / 60.0m;
// Role cost rates map: role → hourly rate
var roleCosts = await _unitOfWork.ShopWorkerRoleCosts.FindAsync(r => r.CompanyId == companyId);
var roleCostMap = roleCosts.ToDictionary(r => r.Role, r => r.HourlyRate);
// Labor cost rate priority: per-user LaborCostPerHour → company LaborCostPerHour → 20% of StandardLaborRate
var companyLaborCostRate = opCosts?.LaborCostPerHour ?? ((opCosts?.StandardLaborRate ?? 0m) * 0.20m);
var companyUsers = await _userManager.Users
.Where(u => u.CompanyId == companyId && u.LaborCostPerHour != null)
.Select(u => new { u.Id, u.LaborCostPerHour })
.ToListAsync();
var userLaborCostMap = companyUsers.ToDictionary(u => u.Id, u => u.LaborCostPerHour!.Value);
// 1. Powder / Material cost
// Priority: PowderUsageLog actuals (sum per coat) > coat.ActualPowderUsedLbs > coat.PowderToOrder (estimated)
var usageLogs = await _unitOfWork.PowderUsageLogs.FindAsync(u => u.JobId == jobId);
var actualByCoat = usageLogs
.GroupBy(u => u.JobItemCoatId)
.ToDictionary(g => g.Key, g => g.Sum(u => u.ActualLbsUsed));
decimal powderCost = 0m;
var powderLines = new List<object>();
bool hasCoatsWithRateButNoQty = false;
@@ -3839,7 +3847,19 @@ public class JobsController : Controller
{
foreach (var coat in item.Coats)
{
var lbs = coat.ActualPowderUsedLbs ?? coat.PowderToOrder ?? 0m;
bool isActual;
decimal lbs;
if (actualByCoat.TryGetValue(coat.Id, out var loggedLbs) && loggedLbs > 0)
{
lbs = loggedLbs;
isActual = true;
}
else
{
lbs = coat.ActualPowderUsedLbs ?? coat.PowderToOrder ?? 0m;
isActual = coat.ActualPowderUsedLbs.HasValue;
}
var costPerLb = coat.PowderCostPerLb ?? 0m;
var lineCost = lbs * costPerLb;
powderCost += lineCost;
@@ -3850,7 +3870,7 @@ public class JobsController : Controller
lbs = Math.Round(lbs, 3),
costPerLb = Math.Round(costPerLb, 4),
total = Math.Round(lineCost, 2),
isActual = coat.ActualPowderUsedLbs.HasValue
isActual
});
}
else if (costPerLb > 0 && lbs == 0)
@@ -3862,20 +3882,23 @@ public class JobsController : Controller
}
// 2. Labor cost
// Priority: per-user LaborCostPerHour → company LaborCostPerHour → 20% of StandardLaborRate
decimal laborCost = 0m;
var laborLines = new List<object>();
foreach (var entry in job.TimeEntries)
{
var rate = entry.Worker != null && roleCostMap.TryGetValue(entry.Worker.Role, out var r) ? r : fallbackLaborRate;
bool usingPerUser = entry.UserId != null && userLaborCostMap.TryGetValue(entry.UserId, out _);
var rate = usingPerUser
? userLaborCostMap[entry.UserId!]
: companyLaborCostRate;
var lineCost = entry.HoursWorked * rate;
laborCost += lineCost;
laborLines.Add(new {
worker = entry.Worker?.Name ?? "Unknown",
role = entry.Worker != null ? System.Text.RegularExpressions.Regex.Replace(entry.Worker.Role.ToString(), "([a-z])([A-Z])", "$1 $2") : "",
worker = entry.UserDisplayName ?? "Unknown",
hours = entry.HoursWorked,
rate = Math.Round(rate, 2),
total = Math.Round(lineCost, 2),
usingFallback = entry.Worker == null || !roleCostMap.ContainsKey(entry.Worker.Role),
usingFallback = !usingPerUser,
stage = entry.Stage,
workDate = entry.WorkDate.ToString("MM/dd/yyyy")
});
@@ -3949,7 +3972,7 @@ public class JobsController : Controller
grossMargin,
quotedMargin,
quotedPrice = Math.Round(job.QuotedPrice, 2),
fallbackLaborRate,
companyLaborCostRate,
powderLines,
laborLines,
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
/// normally enforce for other entity types.
/// </summary>
// GET: Tools/GetShopWorkers - For randomizer wheel
// GET: Tools/GetShopWorkers - Returns active company users for randomizer wheel
[HttpGet]
public async Task<IActionResult> GetShopWorkers()
{
@@ -1219,7 +1219,6 @@ public static class HelpKnowledgeBase
- [Accounts Payable](/Help/AccountsPayable)
- [Equipment & Maintenance](/Help/Equipment)
- [Vendors](/Help/Vendors)
- [Shop Workers](/Help/ShopWorkers)
- [Reports](/Help/Reports)
- [Settings](/Help/Settings)
- [User Profile](/Help/UserProfile)
+1 -2
View File
@@ -270,8 +270,7 @@ builder.Services.AddSingleton<IMapper>(sp =>
cfg.AddProfile(new InventoryProfile());
cfg.AddProfile(new EquipmentProfile());
cfg.AddProfile(new MaintenanceProfile());
cfg.AddProfile(new ShopWorkerProfile());
cfg.AddProfile(new CatalogProfile());
cfg.AddProfile(new CatalogProfile());
cfg.AddProfile(new VendorProfile());
cfg.AddProfile(new LookupProfile());
cfg.AddProfile(new AppointmentProfile());
@@ -1,4 +1,4 @@
@model PowderCoating.Application.DTOs.Company.CompanySettingsDto
@model PowderCoating.Application.DTOs.Company.CompanySettingsDto
@{
ViewData["Title"] = "Company Settings";
ViewData["PageIcon"] = "bi-building";
@@ -375,6 +375,18 @@
<input type="number" step="0.01" class="form-control" id="standardLaborRate" name="StandardLaborRate" value="@(Model.OperatingCosts?.StandardLaborRate ?? 0)" min="0" max="10000" required>
<span class="input-group-text">/hr</span>
</div>
<small class="text-muted">Billing rate used in quotes and pricing</small>
</div>
</div>
<div class="col-md-3">
<div class="mb-3">
<label for="laborCostPerHour" class="form-label">Shop Labor Cost Rate</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" step="0.01" class="form-control" id="laborCostPerHour" name="LaborCostPerHour" value="@(Model.OperatingCosts?.LaborCostPerHour?.ToString() ?? "")" min="0" max="10000" placeholder="@(((Model.OperatingCosts?.StandardLaborRate ?? 0) * 0.20m).ToString("0.00"))">
<span class="input-group-text">/hr</span>
</div>
<small class="text-muted">Actual wage cost for job costing &amp; profit display only &mdash; never shown to customers. Leave blank to default to 20% of billing rate.</small>
</div>
</div>
<div class="col-md-3">
@@ -516,35 +528,6 @@
</div>
</div>
<!-- Role-Based Labor Rates -->
<h6 class="border-bottom pb-2 mb-3 mt-4">Role-Based Labor Cost Rates
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Role-Based Labor Cost Rates"
data-bs-content="Set an optional cost rate per worker role for job profitability calculations. These are your &lt;strong&gt;internal cost rates&lt;/strong&gt; (what you pay), not what you bill customers. If a rate is left blank, the &lt;strong&gt;Standard Labor Rate&lt;/strong&gt; above is used as the fallback.">
<i class="bi bi-question-circle"></i>
</a>
</h6>
<p class="text-muted small">Used for job costing only — not shown to customers. Leave blank to use the Standard Labor Rate.</p>
<div class="table-responsive mb-3">
<table class="table table-sm align-middle" id="roleCostTable">
<thead class="table-light">
<tr>
<th>Role</th>
<th style="width:180px;">Cost Rate / hr</th>
<th style="width:140px;" class="text-muted small">Fallback if blank</th>
</tr>
</thead>
<tbody id="roleCostBody">
<tr><td colspan="3" class="text-center text-muted py-2"><div class="spinner-border spinner-border-sm me-2"></div>Loading...</td></tr>
</tbody>
</table>
</div>
<button type="button" class="btn btn-sm btn-primary" onclick="saveRoleCosts()">
<i class="bi bi-floppy me-1"></i>Save Labor Rates
</button>
<span id="roleCostSaveStatus" class="ms-2 small"></span>
<!-- Pricing & Overhead -->
<h6 class="border-bottom pb-2 mb-3 mt-4">Pricing &amp; Profit
<a tabindex="0" class="help-icon" role="button"
@@ -2949,76 +2932,6 @@
loadOvenCosts();
});
// Reload role costs whenever the Operating Costs tab is shown
document.getElementById('operating-costs-tab')?.addEventListener('shown.bs.tab', () => {
loadRoleCosts();
});
// If Equipment Profile tab is already active on page load, load immediately
if (document.getElementById('quoting-calibration')?.classList.contains('show')) {
loadOvenCosts();
}
// If Operating Costs tab is already active on page load, load role costs immediately
if (document.getElementById('operating-costs')?.classList.contains('show')) {
loadRoleCosts();
}
// ── Role-Based Labor Cost Rates ────────────────────────────────────────
const ROLE_NAMES = ['General Labor','Sandblaster','Coater','Masker','Quality Control','Oven Operator','Supervisor','Maintenance'];
async function loadRoleCosts() {
const resp = await fetch('/CompanySettings/GetRoleCosts');
const saved = await resp.json(); // [{role, hourlyRate}]
const rateMap = {};
saved.forEach(r => rateMap[r.role] = r.hourlyRate);
const fallbackEl = document.getElementById('standardLaborRate');
const fallback = fallbackEl ? `$${parseFloat(fallbackEl.value || 0).toFixed(2)}/hr` : 'Standard Rate';
const tbody = document.getElementById('roleCostBody');
tbody.innerHTML = ROLE_NAMES.map((name, i) => `
<tr>
<td><span class="badge bg-secondary">${name}</span></td>
<td>
<div class="input-group input-group-sm">
<span class="input-group-text">$</span>
<input type="number" step="0.01" min="0" max="999"
class="form-control role-cost-input"
data-role="${i}"
value="${rateMap[i] > 0 ? rateMap[i] : ''}"
placeholder="(use default)">
</div>
</td>
<td class="text-muted small">${fallback}</td>
</tr>`).join('');
}
async function saveRoleCosts() {
const inputs = document.querySelectorAll('.role-cost-input');
const rates = Array.from(inputs).map(el => ({
role: parseInt(el.dataset.role),
hourlyRate: parseFloat(el.value) || 0
}));
const statusEl = document.getElementById('roleCostSaveStatus');
statusEl.textContent = 'Saving...';
statusEl.className = 'ms-2 small text-muted';
const resp = await fetch('/CompanySettings/SaveRoleCosts', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '' },
body: JSON.stringify(rates)
});
const result = await resp.json();
if (result.success) {
statusEl.textContent = '✓ Saved';
statusEl.className = 'ms-2 small text-success';
setTimeout(() => statusEl.textContent = '', 3000);
} else {
statusEl.textContent = result.message || 'Error saving';
statusEl.className = 'ms-2 small text-danger';
}
}
// ── Quote PDF Template ──────────────────────────────────────────────
function syncColorPicker(hex) {
if (/^#[0-9A-Fa-f]{6}$/.test(hex)) {
@@ -106,6 +106,16 @@
<input asp-for="Position" class="form-control" />
<span asp-validation-for="Position" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="LaborCostPerHour" class="form-label">Labor Cost Rate</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input asp-for="LaborCostPerHour" type="number" step="0.01" min="0" max="10000" class="form-control" placeholder="Use company default" />
<span class="input-group-text">/hr</span>
</div>
<span asp-validation-for="LaborCostPerHour" class="text-danger"></span>
<small class="text-muted">Used for internal job costing only &mdash; never shown to customers. Overrides the company default when set. Leave blank to use the shop-wide rate.</small>
</div>
<div class="col-md-6">
<label asp-for="HireDate" class="form-label">Hire Date</label>
<input asp-for="HireDate" class="form-control" type="date" />
@@ -189,22 +189,6 @@
<!-- Shop Management -->
<h2 class="h6 fw-semibold mb-2 text-muted text-uppercase" style="letter-spacing:.05em; font-size:.7rem;">Shop Management</h2>
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex align-items-start gap-3">
<div class="rounded-3 bg-info bg-opacity-10 p-2 flex-shrink-0">
<i class="bi bi-person-badge text-info fs-4"></i>
</div>
<div>
<h5 class="card-title mb-1">Shop Workers</h5>
<p class="card-text text-muted small mb-2">Add floor staff, assign roles like Coater or Sandblaster, and link workers to jobs and maintenance tasks.</p>
<a asp-controller="Help" asp-action="ShopWorkers" class="btn btn-sm btn-outline-info">Read more <i class="bi bi-arrow-right ms-1"></i></a>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
@@ -1,226 +0,0 @@
@{
ViewData["Title"] = "Shop Workers";
}
<div class="d-flex align-items-center gap-2 mb-3">
<a asp-controller="Help" asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a asp-controller="Help" asp-action="Index">Help</a></li>
<li class="breadcrumb-item active">Shop Workers</li>
</ol>
</nav>
</div>
<div class="row g-4">
<div class="col-lg-9">
<section id="overview" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-info-circle text-primary me-2"></i>Overview
</h2>
<p>
Shop Workers are the people who do the hands-on work in your facility — sandblasters, coaters,
maskers, oven operators, and supervisors. Adding your workers to the system lets you assign them
to jobs and maintenance tasks, giving you a clear picture of who is working on what at any time.
</p>
<p>
Shop Workers are separate from system user accounts. A worker does not need to log into the
system — they are simply a record that can be assigned to work. If a worker also needs to log
in and update job statuses themselves, an Administrator can create a linked user account for
them with the <em>Shop Floor</em> role.
</p>
<p>
Find Shop Workers under <strong>Operations &rsaquo; 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 &rsaquo; 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 &amp; Maintenance</a>
help page for details.
</p>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
<div>
If a worker you want to assign does not appear in the dropdown, check that their record is
marked as <strong>Active</strong>. Inactive workers are hidden from assignment lists.
</div>
</div>
</section>
<section id="deactivating-a-worker" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-person-dash text-primary me-2"></i>Deactivating a Worker
</h2>
<p>
When a worker leaves the shop or is no longer available for assignment, deactivate their record
rather than deleting it. Deactivating preserves the history of all jobs they were assigned to,
while removing them from the active assignment dropdowns so they cannot be accidentally selected
for new work.
</p>
<p>To deactivate a worker:</p>
<ol class="mb-3">
<li class="mb-1">Open the worker's Details or Edit page.</li>
<li class="mb-1">Uncheck the <strong>Active</strong> checkbox.</li>
<li class="mb-1">Click <strong>Save</strong>.</li>
</ol>
<p>
Alternatively, use the <strong>Delete</strong> button on the Details page to perform a soft
delete, which has the same effect.
</p>
<div class="alert alert-permanent alert-secondary d-flex gap-2 mb-0" role="alert">
<i class="bi bi-info-circle flex-shrink-0 mt-1"></i>
<div>
If a worker currently has open jobs assigned to them, reassign those jobs first before
deactivating the worker — so the jobs remain clearly owned and nothing falls through the cracks.
</div>
</div>
</section>
</div>
<div class="col-lg-3 d-none d-lg-block">
@{ await Html.RenderPartialAsync("_HelpNav"); }
<div class="card border-0 shadow-sm sticky-top" style="top:80px">
<div class="card-header bg-transparent fw-semibold small text-muted text-uppercase" style="letter-spacing:.05em; font-size:.7rem;">On this page</div>
<div class="card-body p-0">
<nav class="nav flex-column">
<a class="nav-link py-1 px-3 small text-body" href="#overview">Overview</a>
<a class="nav-link py-1 px-3 small text-body" href="#adding-a-worker">Adding a Worker</a>
<a class="nav-link py-1 px-3 small text-body" href="#worker-roles">Worker Roles</a>
<a class="nav-link py-1 px-3 small text-body" href="#assigning-to-jobs">Assigning to Jobs</a>
<a class="nav-link py-1 px-3 small text-body" href="#deactivating-a-worker">Deactivating a Worker</a>
</nav>
</div>
</div>
</div>
</div>
@@ -65,11 +65,7 @@
<div class="px-3 pt-2 pb-1">
<span class="text-muted text-uppercase" style="font-size:.65rem; letter-spacing:.07em; font-weight:600;">Shop Management</span>
</div>
<a class="nav-link py-2 px-3 d-flex align-items-center gap-2 @(currentAction == "ShopWorkers" ? "active fw-semibold text-primary" : "text-body")"
asp-controller="Help" asp-action="ShopWorkers">
<i class="bi bi-person-badge"></i> Shop Workers
</a>
<a class="nav-link py-2 px-3 d-flex align-items-center gap-2 @(currentAction == "Equipment" ? "active fw-semibold text-primary" : "text-body")"
<a class="nav-link py-2 px-3 d-flex align-items-center gap-2 @(currentAction == "Equipment" ? "active fw-semibold text-primary" : "text-body")"
asp-controller="Help" asp-action="Equipment">
<i class="bi bi-tools"></i> Equipment &amp; Maintenance
</a>
@@ -1067,8 +1067,7 @@
var hasEquipment = User.HasClaim("Permission", "ManageEquipment") || User.IsInRole("SuperAdmin");
var hasMaintenance = User.HasClaim("Permission", "ManageMaintenance") || User.IsInRole("SuperAdmin");
var hasFinance = _isAdminOrManager || User.HasClaim("Permission", "ManageFinance");
var hasShopWorkers = _isAdminOrManager || User.HasClaim("Permission", "ManageShopWorkers");
var hasReports = _isAdminOrManager || User.HasClaim("Permission", "ViewReports");
var hasReports = _isAdminOrManager || User.HasClaim("Permission", "ViewReports");
var showOperations = hasCustomers || hasQuotes || hasInvoices || hasJobs || hasCalendar;
var showInventorySection = hasInventory || hasVendors;
var showEquipmentSection = hasEquipment || hasMaintenance;
@@ -12,8 +12,7 @@ document.addEventListener('DOMContentLoaded', function () {
setupCsvImportForm('csvImportMaintenanceForm', 'csvMaintenanceFile', 'csvImportMaintenanceBtn', '/Tools/CsvImportMaintenance', 'csvMaintenanceResults');
setupCsvImportForm('csvImportSettingsForm', 'csvSettingsFile', 'csvImportSettingsBtn', '/Tools/CsvImportCompanySettings', 'csvSettingsResults');
setupCsvImportForm('csvImportVendorsForm', 'csvVendorsFile', 'csvImportVendorsBtn', '/Tools/CsvImportVendors', 'csvVendorsResults');
setupCsvImportForm('csvImportShopWorkersForm', 'csvShopWorkersFile', 'csvImportShopWorkersBtn', '/Tools/CsvImportShopWorkers', 'csvShopWorkersResults');
setupCsvImportForm('csvImportPrepServicesForm', 'csvPrepServicesFile', 'csvImportPrepServicesBtn', '/Tools/CsvImportPrepServices', 'csvPrepServicesResults');
setupCsvImportForm('csvImportPrepServicesForm', 'csvPrepServicesFile', 'csvImportPrepServicesBtn', '/Tools/CsvImportPrepServices', 'csvPrepServicesResults');
});
function setupCsvImportForm(formId, fileInputId, submitBtnId, actionUrl, resultsId) {