diff --git a/src/PowderCoating.Application/Interfaces/ISeedDataService.cs b/src/PowderCoating.Application/Interfaces/ISeedDataService.cs
index d108a62..d60169a 100644
--- a/src/PowderCoating.Application/Interfaces/ISeedDataService.cs
+++ b/src/PowderCoating.Application/Interfaces/ISeedDataService.cs
@@ -47,6 +47,7 @@ public class RemoveSeedDataOptions
public bool OperatingCosts { get; set; }
public bool Bills { get; set; }
public bool Expenses { get; set; }
+ public bool Workers { get; set; }
}
public class SeedDataResult
diff --git a/src/PowderCoating.Infrastructure/Services/SeedDataService.Equipment.cs b/src/PowderCoating.Infrastructure/Services/SeedDataService.Equipment.cs
index 36fa79f..6fef4b5 100644
--- a/src/PowderCoating.Infrastructure/Services/SeedDataService.Equipment.cs
+++ b/src/PowderCoating.Infrastructure/Services/SeedDataService.Equipment.cs
@@ -57,7 +57,7 @@ public partial class SeedDataService
{
new Equipment
{
- EquipmentName = "Batch Powder Coating Oven #1",
+ EquipmentName = "Main Batch Oven",
EquipmentNumber = $"{company.CompanyCode}-OVN-001",
EquipmentType = "Oven",
Manufacturer = "Reliant Finishing Systems",
@@ -78,7 +78,7 @@ public partial class SeedDataService
},
new Equipment
{
- EquipmentName = "Batch Powder Coating Oven #2",
+ EquipmentName = "Small Batch Oven",
EquipmentNumber = $"{company.CompanyCode}-OVN-002",
EquipmentType = "Oven",
Manufacturer = "Reliant Finishing Systems",
@@ -99,7 +99,7 @@ public partial class SeedDataService
},
new Equipment
{
- EquipmentName = "Automated Powder Coating Booth #1",
+ EquipmentName = "Powder Coating Booth",
EquipmentNumber = $"{company.CompanyCode}-BOOTH-001",
EquipmentType = "Spray Booth",
Manufacturer = "Nordson Corporation",
@@ -120,7 +120,7 @@ public partial class SeedDataService
},
new Equipment
{
- EquipmentName = "Manual Powder Coating Booth #2",
+ EquipmentName = "Manual Powder Booth",
EquipmentNumber = $"{company.CompanyCode}-BOOTH-002",
EquipmentType = "Spray Booth",
Manufacturer = "Columbia Coatings",
@@ -162,7 +162,7 @@ public partial class SeedDataService
},
new Equipment
{
- EquipmentName = "Media Blast Room",
+ EquipmentName = "Pressure Pot Blaster",
EquipmentNumber = $"{company.CompanyCode}-BLAST-002",
EquipmentType = "Sandblaster",
Manufacturer = "Clemco Industries",
@@ -183,7 +183,7 @@ public partial class SeedDataService
},
new Equipment
{
- EquipmentName = "Rotary Screw Air Compressor",
+ EquipmentName = "Air Compressor",
EquipmentNumber = $"{company.CompanyCode}-COMP-001",
EquipmentType = "Compressor",
Manufacturer = "Atlas Copco",
@@ -204,28 +204,28 @@ public partial class SeedDataService
},
new Equipment
{
- EquipmentName = "Overhead Conveyor System",
- EquipmentNumber = $"{company.CompanyCode}-CONV-001",
- EquipmentType = "Conveyor",
- Manufacturer = "Pacline Conveyors",
- Model = "PAC-500 Overhead",
+ EquipmentName = "Forklift",
+ EquipmentNumber = $"{company.CompanyCode}-FORK-001",
+ EquipmentType = "Forklift",
+ Manufacturer = "Toyota",
+ Model = "8FGCU25",
SerialNumber = "PAC50034521",
PurchaseDate = DateTime.UtcNow.AddYears(-4),
- PurchasePrice = 52000m,
+ PurchasePrice = 28000m,
WarrantyExpiration = DateTime.UtcNow.AddYears(-2),
Status = EquipmentStatus.Operational,
- Location = "Main Production Line",
+ Location = "Loading Area",
RecommendedMaintenanceIntervalDays = 180,
- LastMaintenanceDate = DateTime.UtcNow.AddDays(-120),
- NextScheduledMaintenance = DateTime.UtcNow.AddDays(60),
- Notes = "500 lb capacity overhead conveyor with power and free sections",
+ LastMaintenanceDate = DateTime.UtcNow.AddDays(-90),
+ NextScheduledMaintenance = DateTime.UtcNow.AddDays(90),
+ Notes = "5,000 lb capacity propane forklift — used for loading/unloading customer parts",
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow.AddYears(-4)
},
new Equipment
{
- EquipmentName = "Parts Washer System",
+ EquipmentName = "Wash Station",
EquipmentNumber = $"{company.CompanyCode}-WASH-001",
EquipmentType = "Washer",
Manufacturer = "Better Engineering",
diff --git a/src/PowderCoating.Infrastructure/Services/SeedDataService.Maintenance.cs b/src/PowderCoating.Infrastructure/Services/SeedDataService.Maintenance.cs
new file mode 100644
index 0000000..fba9e9a
--- /dev/null
+++ b/src/PowderCoating.Infrastructure/Services/SeedDataService.Maintenance.cs
@@ -0,0 +1,146 @@
+using Microsoft.EntityFrameworkCore;
+using PowderCoating.Core.Entities;
+using PowderCoating.Core.Enums;
+
+namespace PowderCoating.Infrastructure.Services;
+
+public partial class SeedDataService
+{
+ ///
+ /// Seeds maintenance records for all seeded equipment: historical completed records,
+ /// upcoming scheduled records, and one overdue record for the Pressure Pot so the
+ /// Equipment Maintenance report always has meaningful data.
+ ///
+ ///
+ ///
+ /// Each piece of equipment gets 2-3 completed historical maintenance records (up to
+ /// 12 months back) plus one upcoming scheduled record. The Pressure Pot additionally
+ /// has one overdue record (past due date, still Scheduled) to populate the overdue
+ /// indicator on the Equipment page.
+ ///
+ ///
+ /// Labor and parts costs are realistic for shop equipment maintenance, giving the
+ /// Equipment Maintenance Cost report non-trivial totals from day one.
+ ///
+ ///
+ /// Idempotency: bails early if any maintenance records already exist for this company's equipment.
+ ///
+ ///
+ private async Task SeedMaintenanceRecordsAsync(Company company)
+ {
+ var equipmentIds = await _context.Set()
+ .IgnoreQueryFilters()
+ .Where(e => e.CompanyId == company.Id && !e.IsDeleted && SeededEquipmentSerials.Contains(e.SerialNumber))
+ .Select(e => e.Id)
+ .ToListAsync();
+
+ if (equipmentIds.Count == 0) return 0;
+
+ var existingCount = await _context.Set()
+ .IgnoreQueryFilters()
+ .CountAsync(m => equipmentIds.Contains(m.EquipmentId));
+ if (existingCount > 0) return 0;
+
+ var equipment = await _context.Set()
+ .IgnoreQueryFilters()
+ .Where(e => equipmentIds.Contains(e.Id))
+ .ToListAsync();
+
+ // Try to grab a worker user to assign as performed-by
+ var worker = await _userManager.Users
+ .Where(u => u.CompanyId == company.Id && u.IsActive)
+ .FirstOrDefaultAsync();
+
+ var now = DateTime.UtcNow;
+ var records = new List();
+
+ // Per-equipment maintenance spec: (type, daysAgoFirst, intervalDays, laborCost, partsCost, notes)
+ // Each equipment gets 2 completed records + 1 scheduled upcoming.
+ // The Pressure Pot also gets 1 overdue record.
+ static (string type, decimal labor, decimal parts, string desc, string work)
+ MaintSpec(int i) => (i % 5) switch
+ {
+ 0 => ("Preventive", 120m, 45m, "Quarterly preventive maintenance", "Inspected elements, cleaned contacts, checked gaskets"),
+ 1 => ("Inspection", 80m, 0m, "Monthly operational inspection", "Checked all systems, calibrated temperature probes"),
+ 2 => ("Repair", 200m, 185m, "Filter and seal replacement", "Replaced intake filters and worn door seals"),
+ 3 => ("Preventive", 140m, 60m, "Semi-annual preventive maintenance", "Lubricated moving parts, replaced wear items"),
+ _ => ("Inspection", 60m, 0m, "Pre-season inspection and cleaning", "Full operational test, cleaned all surfaces"),
+ };
+
+ int idx = 0;
+ foreach (var eq in equipment)
+ {
+ var isPressurePot = eq.SerialNumber == "CLM101223456"; // Media Blast Room / Pressure Pot
+
+ for (int r = 0; r < 2; r++)
+ {
+ var (mtype, labor, parts, desc, work) = MaintSpec(idx + r);
+ var daysAgo = 180 - r * 60 - (idx % 4) * 15;
+ var scheduled = now.AddDays(-daysAgo);
+ var total = labor + parts;
+
+ records.Add(new MaintenanceRecord
+ {
+ EquipmentId = eq.Id,
+ MaintenanceType = mtype,
+ Status = MaintenanceStatus.Completed,
+ Priority = MaintenancePriority.Normal,
+ ScheduledDate = scheduled,
+ CompletedDate = scheduled.AddDays(1),
+ PerformedById = worker?.Id,
+ AssignedUserId = worker?.Id,
+ Description = desc,
+ WorkPerformed = work,
+ LaborCost = labor,
+ PartsCost = parts,
+ TotalCost = total,
+ DowntimeHours = 2m + r,
+ CompanyId = company.Id,
+ CreatedAt = scheduled.AddDays(-7)
+ });
+ }
+
+ // One upcoming scheduled record
+ var upcomingDays = 15 + (idx % 30);
+ records.Add(new MaintenanceRecord
+ {
+ EquipmentId = eq.Id,
+ MaintenanceType = "Preventive",
+ Status = MaintenanceStatus.Scheduled,
+ Priority = MaintenancePriority.Normal,
+ ScheduledDate = now.AddDays(upcomingDays),
+ AssignedUserId = worker?.Id,
+ Description = "Scheduled preventive maintenance",
+ LaborCost = 0m, PartsCost = 0m, TotalCost = 0m,
+ CompanyId = company.Id,
+ CreatedAt = now.AddDays(-7)
+ });
+
+ // Overdue record for the pressure pot only
+ if (isPressurePot)
+ {
+ records.Add(new MaintenanceRecord
+ {
+ EquipmentId = eq.Id,
+ MaintenanceType = "Repair",
+ Status = MaintenanceStatus.Scheduled,
+ Priority = MaintenancePriority.High,
+ ScheduledDate = now.AddDays(-20), // overdue
+ AssignedUserId = worker?.Id,
+ Description = "Filter replacement — OVERDUE",
+ Notes = "Media filter became clogged ahead of schedule. Shop is running reduced blast capacity until repaired.",
+ LaborCost = 0m, PartsCost = 0m, TotalCost = 0m,
+ CompanyId = company.Id,
+ CreatedAt = now.AddDays(-30)
+ });
+ }
+
+ idx++;
+ }
+
+ await _context.Set().AddRangeAsync(records);
+ await _context.SaveChangesAsync();
+
+ return records.Count;
+ }
+}
diff --git a/src/PowderCoating.Infrastructure/Services/SeedDataService.Remove.cs b/src/PowderCoating.Infrastructure/Services/SeedDataService.Remove.cs
index a5f10a8..4e14328 100644
--- a/src/PowderCoating.Infrastructure/Services/SeedDataService.Remove.cs
+++ b/src/PowderCoating.Infrastructure/Services/SeedDataService.Remove.cs
@@ -160,6 +160,10 @@ public partial class SeedDataService
.Where(p => seededJobIds.Contains(p.JobId)).ToListAsync();
if (jobPrepServices.Any()) _context.JobPrepServices.RemoveRange(jobPrepServices);
+ var timeEntries = await _context.Set().IgnoreQueryFilters()
+ .Where(te => seededJobIds.Contains(te.JobId)).ToListAsync();
+ if (timeEntries.Any()) _context.Set().RemoveRange(timeEntries);
+
var jobs = await _context.Jobs.IgnoreQueryFilters()
.Where(j => seededJobIds.Contains(j.Id)).ToListAsync();
_context.Jobs.RemoveRange(jobs);
@@ -397,6 +401,26 @@ public partial class SeedDataService
}
}
+ // --- Shop Workers ---
+ if (options.Workers)
+ {
+ var workerUsers = await _userManager.Users
+ .Where(u => SeededWorkerEmails.Contains(u.Email) && u.CompanyId == companyId)
+ .ToListAsync();
+
+ if (workerUsers.Any())
+ {
+ foreach (var wu in workerUsers)
+ await _userManager.DeleteAsync(wu);
+ totalRemoved += workerUsers.Count;
+ details.Add($"✓ Removed {workerUsers.Count} demo shop worker(s)");
+ }
+ else
+ {
+ details.Add("• No demo shop workers found");
+ }
+ }
+
result.ItemsSeeded = totalRemoved;
result.Details = details;
result.Message = totalRemoved > 0
diff --git a/src/PowderCoating.Infrastructure/Services/SeedDataService.Workers.cs b/src/PowderCoating.Infrastructure/Services/SeedDataService.Workers.cs
new file mode 100644
index 0000000..94bac0e
--- /dev/null
+++ b/src/PowderCoating.Infrastructure/Services/SeedDataService.Workers.cs
@@ -0,0 +1,172 @@
+using Microsoft.EntityFrameworkCore;
+using PowderCoating.Core.Entities;
+using PowderCoating.Shared.Constants;
+
+namespace PowderCoating.Infrastructure.Services;
+
+public partial class SeedDataService
+{
+ ///
+ /// Canonical emails of the 5 demo shop workers. Used as fingerprints in RemoveSeedDataAsync
+ /// to avoid needing a special "IsSeeded" flag on ApplicationUser.
+ ///
+ internal static readonly string[] SeededWorkerEmails =
+ [
+ "mike.sanders@pcldemo.com",
+ "jake.wilson@pcldemo.com",
+ "sarah.brooks@pcldemo.com",
+ "tyler.green@pcldemo.com",
+ "chris.mason@pcldemo.com",
+ ];
+
+ ///
+ /// Seeds 5 named shop workers as ApplicationUser records for the demo company:
+ /// Mike Sanders (Coater), Jake Wilson (Sandblaster), Sarah Brooks (Inspector),
+ /// Tyler Green (General Worker), and Chris Mason (Shop Lead).
+ ///
+ ///
+ /// Workers are ApplicationUser records with CompanyRole = ShopFloor and the
+ /// Employee system role. They are seeded before jobs and time entries so that
+ /// AssignedUserId on Job and UserId on JobTimeEntry can reference them.
+ ///
+ /// Uses a consistent email domain (@pcldemo.com) that will never conflict with
+ /// real user accounts, making them safe to identify and remove on Demo Reset.
+ ///
+ /// Idempotency: bails early if any of the 5 worker emails already exist.
+ ///
+ private async Task SeedShopWorkersAsync(Company company)
+ {
+ var anyExists = await _userManager.Users
+ .AnyAsync(u => SeededWorkerEmails.Contains(u.Email) && u.CompanyId == company.Id);
+ if (anyExists) return 0;
+
+ const string defaultPassword = "Worker123!";
+ int created = 0;
+
+ // (email, firstName, lastName, empNum, position, laborRate)
+ var workers = new (string email, string fn, string ln, string emp, string pos, decimal rate)[]
+ {
+ ("mike.sanders@pcldemo.com", "Mike", "Sanders", "EMP-001", "Coater", 22.00m),
+ ("jake.wilson@pcldemo.com", "Jake", "Wilson", "EMP-002", "Sandblaster", 20.00m),
+ ("sarah.brooks@pcldemo.com", "Sarah", "Brooks", "EMP-003", "Quality Inspector", 24.00m),
+ ("tyler.green@pcldemo.com", "Tyler", "Green", "EMP-004", "General Worker", 18.00m),
+ ("chris.mason@pcldemo.com", "Chris", "Mason", "EMP-005", "Shop Lead", 28.00m),
+ };
+
+ foreach (var (email, fn, ln, emp, pos, rate) in workers)
+ {
+ var user = await _userManager.FindByEmailAsync(email);
+ if (user != null) continue;
+
+ user = new ApplicationUser
+ {
+ UserName = email,
+ Email = email,
+ FirstName = fn,
+ LastName = ln,
+ EmployeeNumber = emp,
+ Department = "Shop Floor",
+ Position = pos,
+ LaborCostPerHour = rate,
+ EmailConfirmed = true,
+ HireDate = DateTime.UtcNow.AddMonths(-12),
+ IsActive = true,
+ CompanyId = company.Id,
+ CompanyRole = AppConstants.CompanyRoles.Worker,
+ CanManageJobs = true,
+ CanViewShopFloor = true,
+ CreatedAt = DateTime.UtcNow.AddMonths(-12)
+ };
+
+ var result = await _userManager.CreateAsync(user, defaultPassword);
+ if (result.Succeeded)
+ {
+ await _userManager.AddToRoleAsync(user, AppConstants.Roles.Employee);
+ created++;
+ }
+ }
+
+ return created;
+ }
+
+ ///
+ /// Seeds job time entries for completed and in-progress jobs, giving the Worker
+ /// Productivity report meaningful data from day one.
+ ///
+ ///
+ /// Each completed or in-progress job receives 2–4 time entries spread across the
+ /// 5 demo workers, with realistic hours for each coating stage (sandblasting,
+ /// masking, coating, curing, inspection). The total hours roughly correlate with
+ /// the job's EstimatedMinutes from its first JobItem.
+ ///
+ /// Idempotency: bails early if any time entries already exist for this company's jobs.
+ ///
+ private async Task SeedJobTimeEntriesAsync(Company company)
+ {
+ var existingCount = await _context.Set()
+ .IgnoreQueryFilters()
+ .CountAsync(te => te.CompanyId == company.Id && !te.IsDeleted);
+ if (existingCount > 0) return 0;
+
+ var workers = await _userManager.Users
+ .Where(u => SeededWorkerEmails.Contains(u.Email) && u.CompanyId == company.Id)
+ .OrderBy(u => u.Email)
+ .ToListAsync();
+
+ if (workers.Count == 0) return 0;
+
+ // Only create entries for jobs that have been worked on
+ var activeJobs = await _context.Set()
+ .IgnoreQueryFilters()
+ .Where(j => j.CompanyId == company.Id && !j.IsDeleted)
+ .Include(j => j.JobStatus)
+ .ToListAsync();
+
+ var workedJobs = activeJobs.Where(j =>
+ j.JobStatus?.StatusCode is
+ "IN_PREPARATION" or "SANDBLASTING" or "MASKING_TAPING" or "CLEANING" or
+ "IN_OVEN" or "COATING" or "CURING" or "QUALITY_CHECK" or
+ "COMPLETED" or "READY_FOR_PICKUP" or "DELIVERED"
+ ).ToList();
+
+ if (workedJobs.Count == 0) return 0;
+
+ string[] stages = ["Sandblasting", "Masking & Prep", "Coating", "Curing", "Inspection"];
+ decimal[] stageHours = [1.5m, 0.75m, 1.25m, 0.5m, 0.5m];
+
+ var entries = new List();
+ int jobIdx = 0;
+
+ foreach (var job in workedJobs)
+ {
+ // 2-4 entries per job cycling through stages
+ var entryCount = 2 + (jobIdx % 3);
+ var workDate = (job.StartedDate ?? job.CreatedAt).AddDays(1);
+
+ for (int e = 0; e < entryCount; e++)
+ {
+ var worker = workers[(jobIdx + e) % workers.Count];
+ var stageIdx = e % stages.Length;
+
+ entries.Add(new JobTimeEntry
+ {
+ JobId = job.Id,
+ UserId = worker.Id,
+ UserDisplayName = worker.FullName,
+ WorkDate = workDate.AddDays(e),
+ HoursWorked = stageHours[stageIdx],
+ Stage = stages[stageIdx],
+ CompanyId = company.Id,
+ CreatedAt = workDate.AddDays(e)
+ });
+ }
+
+ jobIdx++;
+ }
+
+ await _context.Set().AddRangeAsync(entries);
+ await _context.SaveChangesAsync();
+
+ return entries.Count;
+ }
+}
diff --git a/src/PowderCoating.Infrastructure/Services/SeedDataService.cs b/src/PowderCoating.Infrastructure/Services/SeedDataService.cs
index 9220e31..e5217de 100644
--- a/src/PowderCoating.Infrastructure/Services/SeedDataService.cs
+++ b/src/PowderCoating.Infrastructure/Services/SeedDataService.cs
@@ -411,13 +411,17 @@ public partial class SeedDataService : ISeedDataService
}
catch (Exception ex) { errors.Add($"✗ Customers: {ex.Message}"); _context.ChangeTracker.Clear(); }
+ // Workers must be seeded before jobs so AssignedUserId FK resolves
+ await RunSeeder("Shop workers", details, errors, result, () => SeedShopWorkersAsync(company));
await RunSeeder("Equipment", details, errors, result, () => SeedEquipmentAsync(company));
+ await RunSeeder("Maintenance", details, errors, result, () => SeedMaintenanceRecordsAsync(company));
await RunSeeder("Vendors", details, errors, result, () => SeedVendorsAsync(company));
await RunSeeder("Named ovens", details, errors, result, () => SeedOvenCostsAsync(company));
await RunSeeder("Catalog", details, errors, result, () => SeedCatalogAsync(company));
await RunSeeder("Quotes", details, errors, result, () => SeedQuotesAsync(company));
await RunSeeder("Jobs", details, errors, result, () => SeedJobsAsync(company));
await RunSeeder("Job history", details, errors, result, () => SeedJobStatusHistoryAsync(company));
+ await RunSeeder("Time entries", details, errors, result, () => SeedJobTimeEntriesAsync(company));
await RunSeeder("Inv. txns", details, errors, result, () => SeedInventoryTransactionsAsync(company));
await RunSeeder("Invoices", details, errors, result, () => SeedInvoicesAsync(company));
await RunSeeder("Vendor bills", details, errors, result, () => SeedBillsAsync(company));
diff --git a/src/PowderCoating.Web/Controllers/SeedDataController.cs b/src/PowderCoating.Web/Controllers/SeedDataController.cs
index 2f16eec..0e88a5e 100644
--- a/src/PowderCoating.Web/Controllers/SeedDataController.cs
+++ b/src/PowderCoating.Web/Controllers/SeedDataController.cs
@@ -136,6 +136,7 @@ public class SeedDataController : Controller
OperatingCosts = true,
Bills = true,
Expenses = true,
+ Workers = true,
};
var removeResult = await _seedDataService.RemoveSeedDataAsync(demo.Id, removeOptions);