diff --git a/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs b/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs
index 0738287..d7468d7 100644
--- a/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs
+++ b/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs
@@ -755,17 +755,20 @@ public class FinancialReportService : IFinancialReportService
var asOfEnd = asOf.AddDays(1).AddTicks(-1);
var companyName = await GetCompanyNameAsync(companyId);
- var openBills = await _context.Bills
+ // BalanceDue is a computed property — filter on persisted columns in SQL,
+ // then apply BalanceDue > 0 client-side after materialisation.
+ var openBills = (await _context.Bills
.Include(b => b.Vendor)
.Where(b => b.CompanyId == companyId
&& b.Status != BillStatus.Draft
&& b.Status != BillStatus.Voided
&& b.Status != BillStatus.Paid
- && b.BillDate <= asOfEnd
- && b.BalanceDue > 0)
+ && b.BillDate <= asOfEnd)
.OrderBy(b => b.Vendor!.CompanyName)
.ThenBy(b => b.DueDate)
- .ToListAsync();
+ .ToListAsync())
+ .Where(b => b.BalanceDue > 0)
+ .ToList();
static string AgingBucket(int d) => d switch
{
diff --git a/src/PowderCoating.Infrastructure/Services/SeedDataService.ClockEntries.cs b/src/PowderCoating.Infrastructure/Services/SeedDataService.ClockEntries.cs
new file mode 100644
index 0000000..099152c
--- /dev/null
+++ b/src/PowderCoating.Infrastructure/Services/SeedDataService.ClockEntries.cs
@@ -0,0 +1,138 @@
+using Microsoft.EntityFrameworkCore;
+using PowderCoating.Core.Entities;
+using PowderCoating.Core.Enums;
+
+namespace PowderCoating.Infrastructure.Services;
+
+public partial class SeedDataService
+{
+ ///
+ /// Seeds 90 days of facility clock-in/clock-out records for the 3 demo employees
+ /// (weekdays only), with realistic variation: full days, half days, late arrivals,
+ /// early dismissals, and random days off throughout the period.
+ ///
+ ///
+ ///
+ /// All times are stored in UTC. The shop operates on EST (UTC-5), so a 7:30 AM
+ /// clock-in becomes 12:30 UTC, and a 5:00 PM clock-out becomes 22:00 UTC.
+ ///
+ ///
+ /// The pattern for each (employee, weekday) is deterministic — derived from a
+ /// fixed hash of the employee index and day offset — so a reseed always produces
+ /// the same schedule. This prevents reports from looking different on every reset.
+ ///
+ ///
+ /// Day patterns (approximate distribution):
+ /// • Full day (7:30–5:00, ~8.5 h paid) — ~55% of weekdays
+ /// • Late arrival (9:00–10:00 AM in, normal out) — ~15%
+ /// • Early dismissal (normal in, 1:30–3:00 PM out) — ~12%
+ /// • Half day (normal in, 12:00–12:30 PM out, ~4 h) — ~10%
+ /// • Day off (no entry generated) — ~8%
+ ///
+ ///
+ /// Idempotency: returns 0 immediately if any clock entries already exist for
+ /// this company.
+ ///
+ ///
+ private async Task SeedEmployeeClockEntriesAsync(Company company)
+ {
+ var existingCount = await _context.Set()
+ .IgnoreQueryFilters()
+ .CountAsync(e => e.CompanyId == company.Id && !e.IsDeleted);
+
+ if (existingCount > 0) return 0;
+
+ var employees = await _userManager.Users
+ .Where(u => SeededWorkerEmails.Contains(u.Email) && u.CompanyId == company.Id)
+ .OrderBy(u => u.Email)
+ .ToListAsync();
+
+ if (employees.Count == 0) return 0;
+
+ var now = DateTime.UtcNow.Date;
+ var entries = new List();
+
+ // Notes pools for each scenario, giving the time-clock view narrative variety.
+ string[] lateNotes = ["Dropped kids at school", "Doctor appointment AM", "Car trouble — called ahead", "Traffic on I-40", "Dentist — morning slot"];
+ string[] earlyNotes = ["Picking up parts from supplier", "Kid pickup from school", "Doctor appointment PM", "Personal errand — approved", "Left early — make-up hours tomorrow"];
+ string[] halfDayNotes = ["Half day — personal time", "Dentist then half day", "Training seminar AM only", "Family obligation — half day PTO", "Half day approved by manager"];
+ string[] dayOffNotes = []; // no entry = day off, no note needed
+
+ for (int empIdx = 0; empIdx < employees.Count; empIdx++)
+ {
+ var emp = employees[empIdx];
+
+ for (int daysBack = 90; daysBack >= 1; daysBack--)
+ {
+ var date = now.AddDays(-daysBack);
+
+ // Skip weekends
+ if (date.DayOfWeek == DayOfWeek.Saturday || date.DayOfWeek == DayOfWeek.Sunday)
+ continue;
+
+ // Deterministic pattern per (employee, day) — no Random state to maintain.
+ int hash = (empIdx * 37 + daysBack * 13) % 100;
+
+ // ~8% day off
+ if (hash < 8) continue;
+
+ // Base clock-in: 7:30 AM EST (12:30 UTC) ± small jitter
+ // Base clock-out: 5:00 PM EST (22:00 UTC) ± small jitter
+ int inMinuteUtc = 12 * 60 + 30 + (hash % 15); // 12:30–12:44 UTC
+ int outMinuteUtc = 22 * 60 + (hash % 20); // 22:00–22:19 UTC
+
+ string? noteText = null;
+
+ if (hash < 8)
+ {
+ // day off — already handled above
+ }
+ else if (hash < 18)
+ {
+ // ~10% half day — leave around noon
+ outMinuteUtc = 16 * 60 + 30 + (hash % 30); // 16:30–16:59 UTC = 11:30 AM–11:59 AM EST
+ noteText = halfDayNotes[hash % halfDayNotes.Length];
+ }
+ else if (hash < 33)
+ {
+ // ~15% late arrival — arrive 9:00–10:30 AM EST (14:00–15:30 UTC)
+ inMinuteUtc = 14 * 60 + (hash % 90); // 14:00–15:29 UTC
+ noteText = lateNotes[hash % lateNotes.Length];
+ }
+ else if (hash < 45)
+ {
+ // ~12% early dismissal — leave 1:30–3:00 PM EST (18:30–20:00 UTC)
+ outMinuteUtc = 18 * 60 + 30 + (hash % 90); // 18:30–19:59 UTC
+ noteText = earlyNotes[hash % earlyNotes.Length];
+ }
+ // else: ~55% full day — use base in/out as-is
+
+ var clockIn = date.AddMinutes(inMinuteUtc);
+ var clockOut = date.AddMinutes(outMinuteUtc);
+
+ // Safety: never let clockOut <= clockIn (can happen on very short half-days)
+ if (clockOut <= clockIn) clockOut = clockIn.AddHours(4);
+
+ var hours = Math.Round((decimal)(clockOut - clockIn).TotalHours, 2);
+
+ entries.Add(new EmployeeClockEntry
+ {
+ UserId = emp.Id,
+ ClockInTime = clockIn,
+ ClockOutTime = clockOut,
+ HoursWorked = hours,
+ EntryType = ClockEntryType.Work,
+ Notes = noteText,
+ CompanyId = company.Id,
+ CreatedAt = clockIn,
+ });
+ }
+ }
+
+ if (entries.Count == 0) return 0;
+
+ await _context.Set().AddRangeAsync(entries);
+ await _context.SaveChangesAsync();
+ return entries.Count;
+ }
+}
diff --git a/src/PowderCoating.Infrastructure/Services/SeedDataService.Customers.cs b/src/PowderCoating.Infrastructure/Services/SeedDataService.Customers.cs
index 7bc8af4..4483e1c 100644
--- a/src/PowderCoating.Infrastructure/Services/SeedDataService.Customers.cs
+++ b/src/PowderCoating.Infrastructure/Services/SeedDataService.Customers.cs
@@ -6,27 +6,29 @@ namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
///
- /// Seeds customers at a rate of 15 per calendar month from January of the current year
- /// through the current month, mimicking a real shop that acquires customers steadily
- /// as the year progresses. Seeding in January produces 15 customers; seeding in June
- /// produces 90 (15 × 6 months).
+ /// Seeds customers at a random rate of 2-16 per calendar month across a date window that makes
+ /// the shop look like an ongoing business regardless of when the reset happens:
+ ///
+ /// April through December reset: window starts January 1 of the current year.
+ /// January through March reset: window starts 6 months before the current month
+ /// (reaching into the prior year) so the shop does not appear brand-new.
+ ///
///
///
///
/// The first 27 customers are always the hand-crafted anchor accounts (10 commercial,
/// 17 individual) inserted in a deterministic, index-stable order.
- /// maps customer indices 0–9 to specific commercial price profiles, so the commercial
+ /// maps customer indices 0–9 to specific commercial price profiles, so the commercial
/// anchors must always occupy the lowest database IDs for this company.
///
///
- /// Remaining slots in each month (15 − anchors_that_month) are filled with procedurally
- /// generated individual customers drawn from NC-area name and city pools, so the
- /// New Customers per Month chart shows a consistent 15-bar pattern regardless of
- /// the reseed date.
+ /// Anchors are spread evenly across the full window so that commercial accounts appear as
+ /// established relationships. Procedural individual customers fill remaining slots to reach
+ /// each month's random target.
///
///
/// Idempotency: returns 0 immediately if any non-deleted customers already exist for
- /// this company (they are removed by the reset sweep before a full reseed).
+ /// this company (removed by the reset sweep before a full reseed).
///
///
/// The tenant company to seed customers for.
@@ -106,23 +108,53 @@ public partial class SeedDataService
Indiv("Karen", "White", "kwhite@email.com", "(919) 999-0000", "Fuquay-Varina","NC", "27526", "Antique fireplace grate and hardware restoration"),
Indiv("James", "Taylor", "jtaylor@email.com", "(919) 000-1111", "Garner", "NC", "27529", "1955 Ford F100 hot rod build"),
Indiv("Michelle", "Brown", "mbrown@email.com", "(919) 131-4141", "Holly Springs","NC", "27540", "Outdoor furniture set, 6 chairs and table"),
- Indiv("Chris", "Lee", "clee@email.com", "(984) 242-5252", "Raleigh", "NC", "27610", "Custom BMX frame — Candy Red"),
- Indiv("Amanda", "Garcia", "agarcia@email.com", "(919) 353-6363", "Clayton", "NC", "27520", "Motorcycle frame and forks — Flat Black"),
+ Indiv("Chris", "Lee", "clee@email.com", "(984) 242-5252", "Raleigh", "NC", "27610", "Custom BMX frame — Candy Red"),
+ Indiv("Amanda", "Garcia", "agarcia@email.com", "(919) 353-6363", "Clayton", "NC", "27520", "Motorcycle frame and forks — Flat Black"),
Indiv("Kevin", "Martinez", "kmartinez@email.com", "(919) 464-7474", "Wendell", "NC", "27591", "Snowmobile frame and tunnel"),
Indiv("Nancy", "Rodriguez", "nrodriguez@email.com", "(919) 575-8585", "Knightdale", "NC", "27545", "Wrought iron garden trellis and gate"),
Indiv("Brian", "Hall", "bhall@email.com", "(919) 686-9696", "Zebulon", "NC", "27597", "Utility trailer frame and hitch assembly"),
- Indiv("Patricia", "Young", "pyoung@email.com", "(919) 797-0707", "Louisburg", "NC", "27549", "Front porch railings — Gloss Black"),
+ Indiv("Patricia", "Young", "pyoung@email.com", "(919) 797-0707", "Louisburg", "NC", "27549", "Front porch railings — Gloss Black"),
};
- // Spread anchors evenly from Jan 1 of this year through the current month so the
- // New Customers chart shows them distributed rather than all in one bar.
- int currentMonth = now.Month;
+ // ── Date window ───────────────────────────────────────────────────────
+ // Post-Q1 (Apr–Dec): start from January 1 of the current year so the full
+ // year-to-date story is visible in charts.
+ // Q1 (Jan–Mar): look back 6 months into the prior year so the shop does not
+ // appear to have opened the day of the reset.
+ var windowStart = now.Month <= 3
+ ? new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(-6)
+ : new DateTime(now.Year, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+
+ int windowMonths = (now.Year - windowStart.Year) * 12
+ + (now.Month - windowStart.Month) + 1;
+
+ var rng = new Random();
+
+ // Each month gets a random target between 2 and 16 so the customer-growth
+ // graph looks like a real shop instead of a perfectly flat 15/month line.
+ var monthlyTargets = Enumerable.Range(0, windowMonths)
+ .Select(_ => rng.Next(2, 17))
+ .ToArray();
+
+ // Spread anchors evenly across the full window so that commercial accounts
+ // appear as established customers rather than all clustering in one month.
for (int i = 0; i < anchors.Count; i++)
{
- int month = 1 + (i * currentMonth / anchors.Count);
- month = Math.Clamp(month, 1, currentMonth);
- int day = 3 + (i % 20);
- anchors[i].CreatedAt = new DateTime(now.Year, month, day, 8, 0, 0, DateTimeKind.Utc);
+ int slot = i * windowMonths / anchors.Count;
+ slot = Math.Clamp(slot, 0, windowMonths - 1);
+ var baseDate = windowStart.AddMonths(slot);
+ int day = Math.Min(3 + (i % 20), DateTime.DaysInMonth(baseDate.Year, baseDate.Month));
+ anchors[i].CreatedAt = new DateTime(baseDate.Year, baseDate.Month, day, 8, 0, 0, DateTimeKind.Utc);
+ }
+
+ // Count anchors per month slot to know how many procedural customers each
+ // month still needs to reach that slot's random target.
+ var anchorsPerSlot = new int[windowMonths];
+ foreach (var a in anchors)
+ {
+ int slot = (a.CreatedAt.Year - windowStart.Year) * 12
+ + (a.CreatedAt.Month - windowStart.Month);
+ anchorsPerSlot[Math.Clamp(slot, 0, windowMonths - 1)]++;
}
// Insert anchors in deterministic order (commercial first, then individual).
@@ -170,23 +202,16 @@ public partial class SeedDataService
"27591", "27520", "27597", "27312", "27546" };
string[] domains = { "gmail.com", "yahoo.com", "outlook.com", "hotmail.com", "icloud.com", "aol.com" };
- // Count anchors already assigned to each calendar month
- var anchorsPerMonth = new int[currentMonth + 1];
- foreach (var a in anchors)
- {
- if (a.CreatedAt.Month >= 1 && a.CreatedAt.Month <= currentMonth)
- anchorsPerMonth[a.CreatedAt.Month]++;
- }
-
- // Fill each month to exactly 15 customers using procedurally generated individuals.
- // These go into db after the 27 anchors, so job seeder indices 27+ get 0 jobs (default case).
+ // Fill each month slot to the target using procedurally generated individual customers.
int genIdx = 0;
- for (int month = 1; month <= currentMonth; month++)
+ for (int slot = 0; slot < windowMonths; slot++)
{
- int needed = Math.Max(0, 15 - anchorsPerMonth[month]);
- var monthStart = new DateTime(now.Year, month, 1, 9, 0, 0, DateTimeKind.Utc);
+ int needed = Math.Max(0, monthlyTargets[slot] - anchorsPerSlot[slot]);
+ var monthDate = windowStart.AddMonths(slot);
+ int daysInMo = DateTime.DaysInMonth(monthDate.Year, monthDate.Month);
+ var monthBase = new DateTime(monthDate.Year, monthDate.Month, 1, 9, 0, 0, DateTimeKind.Utc);
- for (int slot = 0; slot < needed; slot++, genIdx++)
+ for (int slotI = 0; slotI < needed; slotI++, genIdx++)
{
string fn = firstNames[genIdx % firstNames.Length];
string ln = lastNames[(genIdx / firstNames.Length) % lastNames.Length];
@@ -194,7 +219,9 @@ public partial class SeedDataService
string zip = zips[genIdx % zips.Length];
string email = $"{fn.ToLower()}{ln.ToLower()}{genIdx + 1}@{domains[genIdx % domains.Length]}";
string phone = $"({600 + genIdx % 400}) {200 + genIdx % 800:D3}-{genIdx % 9000 + 1000:D4}";
- int day = needed > 1 ? 1 + (slot * 27 / needed) : 14;
+ int day = needed > 1
+ ? Math.Min(1 + (slotI * (daysInMo - 2) / needed), daysInMo - 1)
+ : 14;
var gen = new Customer
{
@@ -209,7 +236,7 @@ public partial class SeedDataService
PaymentTerms = "Due on receipt",
IsActive = true,
CompanyId = company.Id,
- CreatedAt = monthStart.AddDays(day),
+ CreatedAt = monthBase.AddDays(day),
};
try
diff --git a/src/PowderCoating.Infrastructure/Services/SeedDataService.Extra.cs b/src/PowderCoating.Infrastructure/Services/SeedDataService.Extra.cs
new file mode 100644
index 0000000..5f89f12
--- /dev/null
+++ b/src/PowderCoating.Infrastructure/Services/SeedDataService.Extra.cs
@@ -0,0 +1,411 @@
+using Microsoft.EntityFrameworkCore;
+using PowderCoating.Core.Entities;
+using PowderCoating.Core.Enums;
+
+namespace PowderCoating.Infrastructure.Services;
+
+public partial class SeedDataService
+{
+ // ═══════════════════════════════════════════════════════════════════════════
+ // Job Notes
+ // ═══════════════════════════════════════════════════════════════════════════
+
+ ///
+ /// Seeds realistic shop-floor notes on a subset of jobs so that the job
+ /// detail view looks lived-in. Roughly 70% of jobs receive 1–2 notes;
+ /// the selection and text are deterministic so every reset produces the same set.
+ ///
+ private async Task SeedJobNotesAsync(Company company)
+ {
+ var existingCount = await _context.Set()
+ .IgnoreQueryFilters()
+ .CountAsync(n => n.CompanyId == company.Id && !n.IsDeleted);
+ if (existingCount > 0) return 0;
+
+ var jobs = await _context.Set()
+ .IgnoreQueryFilters()
+ .Where(j => j.CompanyId == company.Id && !j.IsDeleted)
+ .OrderBy(j => j.Id)
+ .ToListAsync();
+ if (jobs.Count == 0) return 0;
+
+ string[] internalNotes =
+ [
+ "Customer confirmed pickup Friday PM — call ahead.",
+ "Requires extra masking around mounting holes — 6 total.",
+ "Rush job — prioritize over batch queue.",
+ "Custom color mix: 70% Gloss Black + 30% Charcoal Grey.",
+ "Check for pitting before coating — surface prep critical.",
+ "Customer wants to inspect before final cure.",
+ "Sandblast to bare metal — all previous coating must come off.",
+ "Note: customer bringing additional parts next week, hold space.",
+ "Frame sections must be coated before assembly — check sequence.",
+ "QC check required — previous run had adhesion issue with this powder.",
+ "Customer paid deposit via check #4412.",
+ "Temperature-sensitive substrate — confirm oven profile before load.",
+ "Left voicemail re: delivery date — awaiting callback.",
+ "Batch with Triangle Offroad order if timing works out.",
+ "Hanging rod #3 reserved for this run.",
+ ];
+ string[] externalNotes =
+ [
+ "Customer requested matte finish — confirmed in writing.",
+ "Delivery arranged for Thursday 9am–12pm.",
+ "Customer approved color sample.",
+ "Special instructions: no masking on inner bore.",
+ "Customer will drop off Wednesday morning.",
+ ];
+
+ var notes = new List();
+ for (int i = 0; i < jobs.Count; i++)
+ {
+ int hash = (i * 31 + 7) % 10;
+ if (hash >= 7) continue; // ~30% of jobs get no notes
+
+ int noteCount = hash < 3 ? 1 : 2;
+ var job = jobs[i];
+
+ for (int n = 0; n < noteCount; n++)
+ {
+ bool isInternal = (i + n) % 4 != 0;
+ var pool = isInternal ? internalNotes : externalNotes;
+
+ notes.Add(new JobNote
+ {
+ JobId = job.Id,
+ Note = pool[(i * 3 + n * 7) % pool.Length],
+ IsImportant = n == 0 && i % 5 == 0,
+ IsInternal = isInternal,
+ CompanyId = company.Id,
+ CreatedAt = job.CreatedAt.AddDays(1 + n),
+ });
+ }
+ }
+
+ if (notes.Count == 0) return 0;
+ await _context.Set().AddRangeAsync(notes);
+ await _context.SaveChangesAsync();
+ return notes.Count;
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // Customer Notes
+ // ═══════════════════════════════════════════════════════════════════════════
+
+ ///
+ /// Seeds account-management notes on the 10 commercial anchor customers and a
+ /// handful of individual customers so that customer detail pages look like a
+ /// working CRM rather than a blank slate.
+ ///
+ private async Task SeedCustomerNotesAsync(Company company)
+ {
+ var existingCount = await _context.Set()
+ .IgnoreQueryFilters()
+ .CountAsync(n => n.CompanyId == company.Id && !n.IsDeleted);
+ if (existingCount > 0) return 0;
+
+ // Targeted notes for named commercial anchor accounts
+ var anchorData = new (string email, string note, bool important)[]
+ {
+ ("matt@carolinafab.com", "Net 30 terms per contract; invoices go to accounting@carolinafab.com.", true),
+ ("matt@carolinafab.com", "Prefers Gloss Black and Hammertone for structural steel — confirm any color changes.", false),
+ ("ctanner@apexmotorsports.com", "Racing season turnaround required within 5 business days.", true),
+ ("ctanner@apexmotorsports.com", "Color approval required before coating — never assume from previous run.", false),
+ ("jpruitt@triangleoffroad.com", "Skid plates must be coated inside and out — customer checks coverage.", true),
+ ("bsmith@smithwelding.com", "Monthly standing PO — call Bill to confirm quantities before start.", false),
+ ("kmorales@raleigharchitectural.com", "Architectural work: surface finish is critical, QC photos required.", true),
+ ("kmorales@raleigharchitectural.com", "Preferred color: RAL 9005 Jet Black. Has approved alternate: Gloss Black.", false),
+ ("tgreco@eastcoastpw.com", "Net 15 — has paid late twice; flag for follow-up at 10 days.", true),
+ ("dshaw@piedmontmetalworks.com", "Heavy gauge steel — plan for extended sandblast and pre-heat time.", false),
+ ("lpatel@caryindustrial.com", "Parts arrive disassembled; coordinate reassembly quote if needed.", false),
+ ("rblake@durhamtech.com", "University purchase order required on every invoice — ask for PO number.", true),
+ ("mcoleman@wakecountyfleet.gov", "Government contract — tax exempt, Net 60. Do not charge sales tax.", true),
+ ("mcoleman@wakecountyfleet.gov", "Fleet contact: Michelle Coleman. Secondary: Facilities Dept (919) 555-0100.", false),
+ };
+
+ // Generic notes for individual customers (applied deterministically to first N individuals)
+ string[] genericIndivNotes =
+ [
+ "Repeat customer — prefers same color family as previous job.",
+ "Customer prefers afternoon pickups — call before loading.",
+ "Cash payment preferred; has paid by check in the past.",
+ "Sensitive to turnaround time — communicate delays early.",
+ "Referred by Carolina Fabrication. Treat as preferred.",
+ ];
+
+ var notes = new List();
+ var now = DateTime.UtcNow;
+
+ // Anchor commercial notes
+ foreach (var (email, note, important) in anchorData)
+ {
+ var customer = await _context.Set()
+ .IgnoreQueryFilters()
+ .FirstOrDefaultAsync(c => c.Email == email && c.CompanyId == company.Id && !c.IsDeleted);
+ if (customer == null) continue;
+
+ notes.Add(new CustomerNote
+ {
+ CustomerId = customer.Id,
+ Note = note,
+ IsImportant = important,
+ CompanyId = company.Id,
+ CreatedAt = customer.CreatedAt.AddDays(2),
+ });
+ }
+
+ // Generic notes on the first 8 individual customers (non-commercial)
+ var individuals = await _context.Set()
+ .IgnoreQueryFilters()
+ .Where(c => c.CompanyId == company.Id && !c.IsDeleted && !c.IsCommercial)
+ .OrderBy(c => c.Id)
+ .Take(8)
+ .ToListAsync();
+
+ for (int i = 0; i < individuals.Count; i++)
+ {
+ notes.Add(new CustomerNote
+ {
+ CustomerId = individuals[i].Id,
+ Note = genericIndivNotes[i % genericIndivNotes.Length],
+ IsImportant = false,
+ CompanyId = company.Id,
+ CreatedAt = individuals[i].CreatedAt.AddDays(3),
+ });
+ }
+
+ if (notes.Count == 0) return 0;
+ await _context.Set().AddRangeAsync(notes);
+ await _context.SaveChangesAsync();
+ return notes.Count;
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // Rework Records
+ // ═══════════════════════════════════════════════════════════════════════════
+
+ ///
+ /// Seeds 7–8 rework records on completed jobs representing a realistic ~5%
+ /// rework rate. Mix of internal defects (shop fault) and customer-reported
+ /// warranty claims. About half are resolved; the rest remain open or in-progress.
+ ///
+ private async Task SeedReworkRecordsAsync(Company company)
+ {
+ var existingCount = await _context.Set()
+ .IgnoreQueryFilters()
+ .CountAsync(r => r.CompanyId == company.Id && !r.IsDeleted);
+ if (existingCount > 0) return 0;
+
+ // Only completed/delivered jobs qualify for rework records
+ var terminalCodes = new[] { "COMPLETED", "DELIVERED", "READY_FOR_PICKUP" };
+ var statusIds = await _context.Set()
+ .IgnoreQueryFilters()
+ .Where(s => s.CompanyId == company.Id && terminalCodes.Contains(s.StatusCode))
+ .Select(s => s.Id)
+ .ToListAsync();
+
+ var completedJobs = await _context.Set()
+ .IgnoreQueryFilters()
+ .Where(j => j.CompanyId == company.Id && !j.IsDeleted && statusIds.Contains(j.JobStatusId))
+ .OrderBy(j => j.Id)
+ .ToListAsync();
+
+ if (completedJobs.Count < 5) return 0;
+
+ // Deterministic selection: pick jobs at indices 1, 4, 7, 10, 13, 16, 19
+ var reworkData = new (int jobIdx, ReworkType type, ReworkReason reason,
+ string description, ReworkDiscoveredBy by, ReworkStatus status,
+ ReworkResolution? resolution, decimal estCost, decimal actCost,
+ bool billable, ReworkPricingType? pricing)[]
+ {
+ (1, ReworkType.InternalDefect, ReworkReason.AdhesionFailure, "Coating delaminating on three mounting points — surface prep insufficient.", ReworkDiscoveredBy.Internal, ReworkStatus.Resolved, ReworkResolution.RecoatedNoCharge, 85m, 95m, false, ReworkPricingType.ShopFault),
+ (4, ReworkType.InternalDefect, ReworkReason.Contamination, "Fish-eye defects across main panel — contamination during coating.", ReworkDiscoveredBy.Internal, ReworkStatus.Resolved, ReworkResolution.RecoatedNoCharge, 60m, 70m, false, ReworkPricingType.ShopFault),
+ (7, ReworkType.CustomerWarranty, ReworkReason.ColorMismatch, "Customer says delivered color does not match approved sample.", ReworkDiscoveredBy.Customer, ReworkStatus.Resolved, ReworkResolution.CustomerCredited, 120m, 0m, false, ReworkPricingType.ShopFault),
+ (10, ReworkType.InternalDefect, ReworkReason.RunsSags, "Runs visible on lower edge — caught during QC inspection.", ReworkDiscoveredBy.Internal, ReworkStatus.Resolved, ReworkResolution.RecoatedNoCharge, 45m, 50m, false, ReworkPricingType.ShopFault),
+ (13, ReworkType.CustomerDamage, ReworkReason.HandlingDamage, "Customer damaged finish during installation — requesting touch-up.", ReworkDiscoveredBy.Customer, ReworkStatus.InProgress, null, 150m, 0m, true, ReworkPricingType.CustomerFull),
+ (16, ReworkType.InternalDefect, ReworkReason.InsufficientCoverage, "Thin spots on inside radius — insufficient powder in recessed areas.", ReworkDiscoveredBy.Internal, ReworkStatus.Open, null, 70m, 0m, false, ReworkPricingType.ShopFault),
+ (19, ReworkType.CustomerWarranty, ReworkReason.OvenIssue, "Early cure failure reported 3 months post-delivery — under investigation.", ReworkDiscoveredBy.Customer, ReworkStatus.Open, null, 200m, 0m, false, ReworkPricingType.ShopFault),
+ };
+
+ var records = new List();
+ foreach (var (jobIdx, type, reason, desc, by, status, resolution, est, act, billable, pricing) in reworkData)
+ {
+ if (jobIdx >= completedJobs.Count) continue;
+ var job = completedJobs[jobIdx];
+ var discoveredDate = job.CompletedDate ?? job.UpdatedAt ?? job.CreatedAt.AddDays(7);
+
+ records.Add(new ReworkRecord
+ {
+ JobId = job.Id,
+ ReworkType = type,
+ Reason = reason,
+ DefectDescription = desc,
+ DiscoveredBy = by,
+ DiscoveredDate = discoveredDate.AddDays(1),
+ EstimatedReworkCost = est,
+ ActualReworkCost = act,
+ IsBillableToCustomer = billable,
+ ReworkPricingType = pricing,
+ Status = status,
+ Resolution = resolution,
+ ResolvedDate = status == ReworkStatus.Resolved ? discoveredDate.AddDays(5) : null,
+ ResolutionNotes = status == ReworkStatus.Resolved ? "Recoated and re-inspected. Customer notified." : null,
+ CompanyId = company.Id,
+ CreatedAt = discoveredDate.AddDays(1),
+ });
+ }
+
+ if (records.Count == 0) return 0;
+ await _context.Set().AddRangeAsync(records);
+ await _context.SaveChangesAsync();
+ return records.Count;
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // Deposits
+ // ═══════════════════════════════════════════════════════════════════════════
+
+ ///
+ /// Seeds ~18 deposits spread across commercial and individual jobs.
+ /// Deposits on jobs that have paid invoices are marked applied; deposits on
+ /// open or pending jobs remain unapplied, giving the Deposits module a realistic mix.
+ ///
+ private async Task SeedDepositsAsync(Company company)
+ {
+ var existingCount = await _context.Set()
+ .IgnoreQueryFilters()
+ .CountAsync(d => d.CompanyId == company.Id && !d.IsDeleted);
+ if (existingCount > 0) return 0;
+
+ // Grab jobs with their invoices and customer info
+ var jobsWithInvoices = await _context.Set()
+ .IgnoreQueryFilters()
+ .Where(j => j.CompanyId == company.Id && !j.IsDeleted)
+ .OrderBy(j => j.Id)
+ .ToListAsync();
+
+ var invoicesByJob = await _context.Set()
+ .IgnoreQueryFilters()
+ .Where(i => i.CompanyId == company.Id && !i.IsDeleted && i.JobId.HasValue)
+ .ToDictionaryAsync(i => i.JobId!.Value, i => i);
+
+ // Checking account for deposit account ID
+ var checkingAccount = await _context.Set()
+ .IgnoreQueryFilters()
+ .FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted
+ && a.AccountNumber == "1000");
+
+ if (jobsWithInvoices.Count == 0) return 0;
+
+ // Deposit amounts and methods for variety
+ decimal[] amounts = [150m, 200m, 250m, 300m, 400m, 500m, 750m, 100m, 350m];
+ var methods = new[] { PaymentMethod.Cash, PaymentMethod.Check, PaymentMethod.CreditDebitCard,
+ PaymentMethod.BankTransferACH, PaymentMethod.Check, PaymentMethod.Cash };
+
+ var deposits = new List();
+ int depSeq = 1;
+
+ // Seed a deposit for every 3rd job (deterministic ~33% coverage)
+ foreach (var (job, idx) in jobsWithInvoices.Select((j, i) => (j, i)))
+ {
+ if (idx % 3 != 0) continue;
+ if (deposits.Count >= 20) break;
+
+ var receivedDate = job.CreatedAt.AddDays(1);
+ decimal amount = amounts[idx % amounts.Length];
+ var method = methods[idx % methods.Length];
+ string yymm = receivedDate.ToString("yyMM");
+ string receipt = $"DEP-{yymm}-{depSeq:D4}";
+
+ invoicesByJob.TryGetValue(job.Id, out var invoice);
+ bool isApplied = invoice != null
+ && invoice.Status is InvoiceStatus.Paid or InvoiceStatus.PartiallyPaid;
+
+ deposits.Add(new Deposit
+ {
+ ReceiptNumber = receipt,
+ CustomerId = job.CustomerId,
+ JobId = job.Id,
+ Amount = amount,
+ PaymentMethod = method,
+ ReceivedDate = receivedDate,
+ Reference = method == PaymentMethod.Check ? $"CHK #{4000 + idx}" : null,
+ Notes = isApplied ? "Applied to invoice on creation." : null,
+ DepositAccountId = checkingAccount?.Id,
+ AppliedToInvoiceId = isApplied ? invoice!.Id : null,
+ AppliedDate = isApplied ? invoice!.InvoiceDate : null,
+ CompanyId = company.Id,
+ CreatedAt = receivedDate,
+ });
+
+ depSeq++;
+ }
+
+ if (deposits.Count == 0) return 0;
+ await _context.Set().AddRangeAsync(deposits);
+ await _context.SaveChangesAsync();
+ return deposits.Count;
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // Bank Reconciliations
+ // ═══════════════════════════════════════════════════════════════════════════
+
+ ///
+ /// Seeds 3 completed monthly bank reconciliations on the primary checking account
+ /// covering the 3 full calendar months immediately before the current month.
+ /// Balances are approximated from the account's opening balance plus a realistic
+ /// monthly cash-flow trajectory, so they look plausible without needing to match
+ /// actual transaction totals (which vary per reset).
+ ///
+ private async Task SeedBankReconciliationsAsync(Company company)
+ {
+ var existingCount = await _context.Set()
+ .IgnoreQueryFilters()
+ .CountAsync(r => r.CompanyId == company.Id && !r.IsDeleted);
+ if (existingCount > 0) return 0;
+
+ var checkingAccount = await _context.Set()
+ .IgnoreQueryFilters()
+ .FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted
+ && a.AccountNumber == "1000");
+ if (checkingAccount == null) return 0;
+
+ var now = DateTime.UtcNow;
+ var reconciliations = new List();
+
+ // Build 3 months of completed reconciliations ending the last day of each month.
+ // Balances step up ~$4-6k per month to reflect a moderately profitable shop.
+ decimal[] endingBalances = [62_400m, 67_800m, 73_250m];
+
+ for (int i = 3; i >= 1; i--)
+ {
+ var statementMonth = new DateTime(now.Year, now.Month, 1).AddMonths(-i);
+ var statementDate = new DateTime(statementMonth.Year, statementMonth.Month,
+ DateTime.DaysInMonth(statementMonth.Year, statementMonth.Month),
+ 23, 59, 59, DateTimeKind.Utc);
+
+ int balIdx = 3 - i; // 0,1,2
+ decimal ending = endingBalances[balIdx];
+ decimal beginning = balIdx == 0 ? ending - 5_200m : endingBalances[balIdx - 1];
+
+ reconciliations.Add(new BankReconciliation
+ {
+ AccountId = checkingAccount.Id,
+ StatementDate = statementDate,
+ BeginningBalance = beginning,
+ EndingBalance = ending,
+ Status = BankReconciliationStatus.Completed,
+ CompletedAt = statementDate.AddDays(3),
+ CompletedBy = "demo@powdercoatinglogix.com",
+ Notes = $"Monthly close — {statementMonth:MMMM yyyy}. All transactions cleared.",
+ CompanyId = company.Id,
+ CreatedAt = statementDate.AddDays(3),
+ });
+ }
+
+ await _context.Set().AddRangeAsync(reconciliations);
+ await _context.SaveChangesAsync();
+ return reconciliations.Count;
+ }
+}
diff --git a/src/PowderCoating.Infrastructure/Services/SeedDataService.JobStatusHistory.cs b/src/PowderCoating.Infrastructure/Services/SeedDataService.JobStatusHistory.cs
index ce54547..14649b1 100644
--- a/src/PowderCoating.Infrastructure/Services/SeedDataService.JobStatusHistory.cs
+++ b/src/PowderCoating.Infrastructure/Services/SeedDataService.JobStatusHistory.cs
@@ -6,157 +6,122 @@ namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
///
- /// Seeds a plausible status-transition history for every job belonging to the company,
- /// reconstructing the sequence of transitions a job must have passed through to reach
- /// its current status.
+ /// Seeds transition records for every completed, delivered,
+ /// or ready-for-pickup job so the Job Cycle Time report can calculate time-per-stage data.
///
///
///
- /// Idempotency: returns 0 immediately if any non-deleted history rows already exist for
- /// this company.
+ /// For each qualifying job the seeder builds a realistic stage sequence:
+ /// PENDING → IN_PREPARATION → (SANDBLASTING if any item requires it)
+ /// → (MASKING_TAPING if any item requires it) → CLEANING → IN_OVEN
+ /// → COATING → CURING → QUALITY_CHECK → [terminal status].
///
///
- /// The method does not record arbitrary transitions — it follows the canonical 14-step
- /// pipeline array (PENDING → QUOTED → APPROVED → … → DELIVERED) and generates
- /// one row per transition step, from PENDING up to
- /// and including the job's current status.
+ /// 85 % of the job's total cycle time (CreatedAt → CompletedDate) is
+ /// distributed across work stages using fixed per-stage weights that reflect realistic
+ /// relative durations (e.g. SANDBLASTING > CLEANING). The remaining 15 %
+ /// is left as residual "terminal status" time, which surfaces correctly in the report's
+ /// last-entry formula (job.CompletedDate − last.ChangedDate).
///
///
- /// Terminal side-branch statuses are handled explicitly:
- ///
- /// ON_HOLD — assumed to have reached QUALITY_CHECK before pausing.
- /// CANCELLED — assumed to have been cancelled from IN_PREPARATION.
- ///
- ///
- ///
- /// Transition timestamps are spread ~6 hours apart starting from job.CreatedAt.
- /// This is an approximation chosen for demo realism; actual production transitions record
- /// the wall-clock time at which a user changes the status. A safety clamp prevents any
- /// generated timestamp from exceeding DateTime.UtcNow.
- ///
- ///
- /// All history rows are batched into a single AddRangeAsync / SaveChangesAsync
- /// call for performance, since the total count can be several hundred rows (50 jobs × up
- /// to 14 transitions each).
+ /// Idempotency: returns 0 immediately if any history records already exist for
+ /// this company, matching the pattern used by all other partial seeders.
///
///
- /// The tenant company to seed job status history for.
- /// Total number of history rows inserted, or 0 if already seeded or no jobs exist.
+ /// The tenant company to seed history for.
+ /// Number of history records inserted, or 0 if already seeded.
private async Task SeedJobStatusHistoryAsync(Company company)
{
var existingCount = await _context.Set()
.IgnoreQueryFilters()
.CountAsync(h => h.CompanyId == company.Id && !h.IsDeleted);
- if (existingCount > 0)
- return 0;
+ if (existingCount > 0) return 0;
- // Load all job status lookups into a code → id map
- var statusMap = await _context.Set()
- .IgnoreQueryFilters()
- .Where(s => s.CompanyId == company.Id)
- .ToDictionaryAsync(s => s.StatusCode, s => s.Id);
+ // Only completed-terminal jobs have a meaningful CompletedDate to calculate cycle time.
+ // CANCELLED is excluded — the report cares only about successfully finished work.
+ var terminalCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
- // Load jobs with their current status
var jobs = await _context.Set()
.IgnoreQueryFilters()
+ .Include(j => j.JobItems)
.Include(j => j.JobStatus)
- .Where(j => j.CompanyId == company.Id && !j.IsDeleted)
- .OrderBy(j => j.Id)
+ .Where(j => j.CompanyId == company.Id && !j.IsDeleted && j.CompletedDate.HasValue)
.ToListAsync();
- if (jobs.Count == 0 || statusMap.Count == 0)
- return 0;
+ jobs = jobs.Where(j => terminalCodes.Contains(j.JobStatus.StatusCode)).ToList();
+ if (jobs.Count == 0) return 0;
- // Ordered pipeline — each status code in the order a job advances through it.
- // ON_HOLD and CANCELLED are terminal side-branches handled separately.
- var pipeline = new[]
- {
- "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION",
- "SANDBLASTING", "MASKING_TAPING", "CLEANING", "IN_OVEN",
- "COATING", "CURING", "QUALITY_CHECK",
- "COMPLETED", "READY_FOR_PICKUP", "DELIVERED"
- };
+ var statuses = await _context.Set()
+ .IgnoreQueryFilters()
+ .Where(s => s.CompanyId == company.Id)
+ .ToDictionaryAsync(s => s.StatusCode, s => s);
- var pipelineIndex = pipeline
- .Select((code, idx) => (code, idx))
- .ToDictionary(t => t.code, t => t.idx);
-
- var history = new List();
- var now = DateTime.UtcNow;
+ var records = new List();
foreach (var job in jobs)
{
- var currentCode = job.JobStatus.StatusCode;
+ var totalSeconds = (job.CompletedDate!.Value - job.CreatedAt).TotalSeconds;
+ if (totalSeconds < 60) continue; // skip malformed dates
- // Determine the sequence of transitions that happened to reach current state.
- // For ON_HOLD: assume it came from QUALITY_CHECK before going on hold.
- // For CANCELLED: assume cancelled from APPROVED or IN_PREPARATION.
- string[] codesTraversed;
+ var needsSand = job.JobItems.Any(i => i.RequiresSandblasting);
+ var needsMask = job.JobItems.Any(i => i.RequiresMasking);
- if (currentCode == "ON_HOLD")
+ // Build ordered stage list; the last element is the terminal "to" status only —
+ // it never appears as a "from" and is not assigned a work weight.
+ var stages = new List { "PENDING", "IN_PREPARATION" };
+ if (needsSand) stages.Add("SANDBLASTING");
+ if (needsMask) stages.Add("MASKING_TAPING");
+ stages.AddRange(new[] { "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK" });
+ stages.Add(job.JobStatus.StatusCode);
+
+ // Time weight for each "from" status — reflects typical relative hours in that stage.
+ static double Weight(string code) => code switch
{
- // Traversed up to QUALITY_CHECK then went ON_HOLD
- codesTraversed = [.. pipeline.Take(pipelineIndex["QUALITY_CHECK"] + 1), "ON_HOLD"];
- }
- else if (currentCode == "CANCELLED")
+ "PENDING" => 2.0, // intake / scheduling buffer
+ "IN_PREPARATION" => 1.5, // disassembly, hang, pre-inspect
+ "SANDBLASTING" => 2.0, // media blast + blow-off
+ "MASKING_TAPING" => 1.0, // tape & plug work
+ "CLEANING" => 0.5, // chemical wash + dry
+ "IN_OVEN" => 1.5, // pre-heat before coating
+ "COATING" => 1.5, // powder application
+ "CURING" => 1.0, // oven cure cycle
+ "QUALITY_CHECK" => 0.5, // inspection & touch-up
+ _ => 0.5,
+ };
+
+ // Work stages are all entries except the terminal "to" at the end.
+ int n = stages.Count;
+ var workWeights = stages.Take(n - 1).Select(Weight).ToList();
+ double totalWeight = workWeights.Sum();
+ // 85% of cycle time covers the work stages; 15% becomes terminal-status residual
+ // so (job.CompletedDate − last.ChangedDate) produces a non-zero, plausible value.
+ double workSeconds = totalSeconds * 0.85;
+
+ var currentDate = job.CreatedAt;
+ for (int i = 0; i < n - 1; i++)
{
- // Cancelled from IN_PREPARATION
- codesTraversed = ["PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "CANCELLED"];
- }
- else if (pipelineIndex.TryGetValue(currentCode, out int curIdx))
- {
- // Normal pipeline job — traversed from PENDING up to current status
- codesTraversed = pipeline.Take(curIdx + 1).ToArray();
- }
- else
- {
- // Unknown status — just record a single PENDING → currentCode entry
- codesTraversed = ["PENDING", currentCode];
- }
+ if (!statuses.TryGetValue(stages[i], out var fromLookup)) continue;
+ if (!statuses.TryGetValue(stages[i + 1], out var toLookup)) continue;
- // Spread transition dates backwards from job.CreatedAt.
- // Each step took roughly 4–8 hours, so transitions are spaced a few hours apart.
- // Jobs further along in the pipeline have older start dates.
- var stepCount = codesTraversed.Length - 1; // number of transitions
- if (stepCount <= 0) continue;
+ currentDate = currentDate.AddSeconds(workSeconds * workWeights[i] / totalWeight);
- // Job was created at job.CreatedAt; each transition is spaced ~6h apart
- // so the first transition (PENDING→QUOTED) happened ~6h after creation, etc.
- for (int t = 0; t < stepCount; t++)
- {
- var fromCode = codesTraversed[t];
- var toCode = codesTraversed[t + 1];
-
- if (!statusMap.TryGetValue(fromCode, out int fromId)) continue;
- if (!statusMap.TryGetValue(toCode, out int toId)) continue;
-
- // Spread: first transitions happened closer to job creation,
- // later ones closer to now. Add a few hours per step.
- var hoursOffset = (t + 1) * 6;
- var changedDate = job.CreatedAt.AddHours(hoursOffset);
-
- // Don't let transitions exceed "now"
- if (changedDate > now) changedDate = now.AddMinutes(-(stepCount - t) * 10);
-
- history.Add(new JobStatusHistory
+ records.Add(new JobStatusHistory
{
- JobId = job.Id,
- FromStatusId = fromId,
- ToStatusId = toId,
- ChangedDate = changedDate,
- Notes = null,
+ JobId = job.Id,
+ FromStatusId = fromLookup.Id,
+ ToStatusId = toLookup.Id,
+ ChangedDate = currentDate,
CompanyId = company.Id,
- CreatedAt = changedDate
+ CreatedAt = currentDate,
});
}
}
- if (history.Count == 0) return 0;
+ if (records.Count == 0) return 0;
- await _context.Set().AddRangeAsync(history);
+ await _context.Set().AddRangeAsync(records);
await _context.SaveChangesAsync();
-
- return history.Count;
+ return records.Count;
}
}
diff --git a/src/PowderCoating.Infrastructure/Services/SeedDataService.Remove.cs b/src/PowderCoating.Infrastructure/Services/SeedDataService.Remove.cs
index bc853c3..a53d30f 100644
--- a/src/PowderCoating.Infrastructure/Services/SeedDataService.Remove.cs
+++ b/src/PowderCoating.Infrastructure/Services/SeedDataService.Remove.cs
@@ -136,6 +136,12 @@ public partial class SeedDataService
// Tier 1 — pure leaf records (block nothing of their own)
await Sweep(); // FK → Jobs (Cascade by convention — sweep before jobs)
+ await Sweep(); // RESTRICT → ApplicationUser — must go before workers
+ await Sweep(); // NO_ACTION → Jobs
+ await Sweep(); // NO_ACTION → Customers
+ await Sweep(); // NO_ACTION → Jobs
+ await Sweep(); // NO_ACTION → Customers, Jobs, Invoices, Quotes
+ await Sweep(); // FK → Accounts (accounts stay, but sweep for clean reset)
await Sweep(); // NO_ACTION → Jobs, JobItems, JobItemCoats
await Sweep(); // NO_ACTION → Jobs, JobItems, JobItemCoats
await Sweep(); // NO_ACTION → Customers, Invoices, Quotes
@@ -653,6 +659,18 @@ public partial class SeedDataService
if (workerUsers.Any())
{
+ // EmployeeClockEntry has DeleteBehavior.Restrict on UserId — delete
+ // clock entries first so the user delete succeeds without a FK violation.
+ var workerUserIds = workerUsers.Select(u => u.Id).ToList();
+ var clockEntries = await _context.Set().IgnoreQueryFilters()
+ .Where(e => workerUserIds.Contains(e.UserId)).ToListAsync();
+ if (clockEntries.Any())
+ {
+ _context.Set().RemoveRange(clockEntries);
+ await _context.SaveChangesAsync();
+ details.Add($"✓ Removed {clockEntries.Count} clock entry/entries");
+ }
+
foreach (var wu in workerUsers)
await _userManager.DeleteAsync(wu);
totalRemoved += workerUsers.Count;
diff --git a/src/PowderCoating.Infrastructure/Services/SeedDataService.Workers.cs b/src/PowderCoating.Infrastructure/Services/SeedDataService.Workers.cs
index d3429c8..f06f59d 100644
--- a/src/PowderCoating.Infrastructure/Services/SeedDataService.Workers.cs
+++ b/src/PowderCoating.Infrastructure/Services/SeedDataService.Workers.cs
@@ -7,32 +7,26 @@ 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.
+ /// Canonical emails of the 3 demo employees (2 workers + 1 manager). 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).
+ /// Seeds 3 named employees as ApplicationUser records for the demo company:
+ /// Mike Sanders (Coater, Worker), Jake Wilson (Sandblaster, Worker),
+ /// and Sarah Brooks (Shop Manager, Manager role with broader permissions).
///
///
- /// 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.
+ /// Workers are seeded before jobs and time entries so that AssignedUserId on Job
+ /// and UserId on JobTimeEntry and EmployeeClockEntry can reference them.
+ /// Uses @pcldemo.com email domain — will never conflict with real accounts.
+ /// Idempotency: bails early if any of the 3 emails already exist for this company.
///
private async Task SeedShopWorkersAsync(Company company)
{
@@ -40,50 +34,71 @@ public partial class SeedDataService
.AnyAsync(u => SeededWorkerEmails.Contains(u.Email) && u.CompanyId == company.Id);
if (anyExists) return 0;
- const string defaultPassword = "Worker123!";
+ const string pwd = "Worker123!Demo";
+ var hireDate = DateTime.UtcNow.AddMonths(-18);
int created = 0;
- // (email, firstName, lastName, empNum, position, laborRate)
- var workers = new (string email, string fn, string ln, string emp, string pos, decimal rate)[]
+ // ── 2 shop workers ────────────────────────────────────────────────────────
+ foreach (var (email, fn, ln, emp, pos, rate) in new (string, string, string, string, string, decimal)[]
{
- ("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)
+ ("mike.sanders@pcldemo.com", "Mike", "Sanders", "EMP-001", "Coater", 22.00m),
+ ("jake.wilson@pcldemo.com", "Jake", "Wilson", "EMP-002", "Sandblaster", 20.00m),
+ })
{
- var user = await _userManager.FindByEmailAsync(email);
- if (user != null) continue;
-
- user = new ApplicationUser
+ if (await _userManager.FindByEmailAsync(email) != null) continue;
+ var user = new ApplicationUser
{
- UserName = email,
- Email = email,
- FirstName = fn,
- LastName = ln,
- EmployeeNumber = emp,
+ UserName = email, Email = email,
+ FirstName = fn, LastName = ln,
+ EmployeeNumber = emp, Position = pos,
Department = "Shop Floor",
- Position = pos,
LaborCostPerHour = rate,
- EmailConfirmed = true,
- HireDate = DateTime.UtcNow.AddMonths(-12),
- IsActive = true,
+ EmailConfirmed = true, IsActive = true,
+ HireDate = hireDate,
CompanyId = company.Id,
CompanyRole = AppConstants.CompanyRoles.Worker,
CanManageJobs = true,
CanViewShopFloor = true,
- CreatedAt = DateTime.UtcNow.AddMonths(-12)
+ CreatedAt = hireDate
};
+ var r = await _userManager.CreateAsync(user, pwd);
+ if (!r.Succeeded)
+ throw new InvalidOperationException(
+ $"Failed to create {email}: {string.Join("; ", r.Errors.Select(e => e.Description))}");
+ await _userManager.AddToRoleAsync(user, AppConstants.Roles.Employee);
+ created++;
+ }
- var result = await _userManager.CreateAsync(user, defaultPassword);
- if (result.Succeeded)
+ // ── 1 manager ─────────────────────────────────────────────────────────────
+ const string managerEmail = "sarah.brooks@pcldemo.com";
+ if (await _userManager.FindByEmailAsync(managerEmail) == null)
+ {
+ var mgr = new ApplicationUser
{
- await _userManager.AddToRoleAsync(user, AppConstants.Roles.Employee);
- created++;
- }
+ UserName = managerEmail, Email = managerEmail,
+ FirstName = "Sarah", LastName = "Brooks",
+ EmployeeNumber = "EMP-003", Position = "Shop Manager",
+ Department = "Management",
+ LaborCostPerHour = 32.00m,
+ EmailConfirmed = true, IsActive = true,
+ HireDate = hireDate.AddMonths(-6), // hired before the workers
+ CompanyId = company.Id,
+ CompanyRole = AppConstants.CompanyRoles.Manager,
+ CanManageJobs = true, CanViewShopFloor = true,
+ CanManageCustomers = true, CanCreateQuotes = true,
+ CanApproveQuotes = true, CanManageCalendar = true,
+ CanViewCalendar = true, CanManageProducts = true,
+ CanViewProducts = true, CanManageEquipment = true,
+ CanManageMaintenance = true, CanManageInventory = true,
+ CanViewReports = true,
+ CreatedAt = hireDate.AddMonths(-6)
+ };
+ var r = await _userManager.CreateAsync(mgr, pwd);
+ if (!r.Succeeded)
+ throw new InvalidOperationException(
+ $"Failed to create {managerEmail}: {string.Join("; ", r.Errors.Select(e => e.Description))}");
+ await _userManager.AddToRoleAsync(mgr, AppConstants.Roles.Employee);
+ created++;
}
return created;
diff --git a/src/PowderCoating.Infrastructure/Services/SeedDataService.cs b/src/PowderCoating.Infrastructure/Services/SeedDataService.cs
index c00036f..898e4cb 100644
--- a/src/PowderCoating.Infrastructure/Services/SeedDataService.cs
+++ b/src/PowderCoating.Infrastructure/Services/SeedDataService.cs
@@ -427,6 +427,7 @@ public partial class SeedDataService : ISeedDataService
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("Clock entries", details, errors, result, () => SeedEmployeeClockEntriesAsync(company));
await RunSeeder("Inv. txns", details, errors, result, () => SeedInventoryTransactionsAsync(company));
await RunSeeder("Invoices", details, errors, result, () => SeedInvoicesAsync(company));
await RunSeeder("AI predictions", details, errors, result, () => SeedAiPredictionsAsync(company));
@@ -477,7 +478,12 @@ public partial class SeedDataService : ISeedDataService
}
catch (Exception ex) { errors.Add($"✗ Account opening balances: {ex.Message}"); _context.ChangeTracker.Clear(); }
- await RunSeeder("Appointments", details, errors, result, () => SeedAppointmentsAsync(company));
+ await RunSeeder("Appointments", details, errors, result, () => SeedAppointmentsAsync(company));
+ await RunSeeder("Job notes", details, errors, result, () => SeedJobNotesAsync(company));
+ await RunSeeder("Customer notes", details, errors, result, () => SeedCustomerNotesAsync(company));
+ await RunSeeder("Rework records", details, errors, result, () => SeedReworkRecordsAsync(company));
+ await RunSeeder("Deposits", details, errors, result, () => SeedDepositsAsync(company));
+ await RunSeeder("Bank recon", details, errors, result, () => SeedBankReconciliationsAsync(company));
if (company.CompanyCode == "DEMO")
{
diff --git a/src/PowderCoating.Web/Controllers/CompanySettingsController.cs b/src/PowderCoating.Web/Controllers/CompanySettingsController.cs
index 7dad626..b3aeffd 100644
--- a/src/PowderCoating.Web/Controllers/CompanySettingsController.cs
+++ b/src/PowderCoating.Web/Controllers/CompanySettingsController.cs
@@ -181,6 +181,8 @@ public class CompanySettingsController : Controller
? (DateTime?)company.BookLockedThrough.Value.ToLocalTime()
: null;
+ ViewBag.IsDemoCompany = company.CompanyCode == "DEMO";
+
return View(dto);
}
catch (FormatException fex)
diff --git a/src/PowderCoating.Web/Controllers/DemoController.cs b/src/PowderCoating.Web/Controllers/DemoController.cs
new file mode 100644
index 0000000..0488189
--- /dev/null
+++ b/src/PowderCoating.Web/Controllers/DemoController.cs
@@ -0,0 +1,96 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using PowderCoating.Application.Interfaces;
+using PowderCoating.Core.Interfaces;
+using PowderCoating.Shared.Constants;
+
+namespace PowderCoating.Web.Controllers;
+
+///
+/// Allows company admins logged into the DEMO company to reset demo data without
+/// switching to a SuperAdmin account. The CompanyCode guard ensures this action
+/// cannot affect any real tenant even if someone crafts a direct POST.
+///
+[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
+public class DemoController : Controller
+{
+ private readonly ISeedDataService _seedDataService;
+ private readonly ITenantContext _tenantContext;
+ private readonly IUnitOfWork _unitOfWork;
+ private readonly ILogger _logger;
+
+ public DemoController(
+ ISeedDataService seedDataService,
+ ITenantContext tenantContext,
+ IUnitOfWork unitOfWork,
+ ILogger logger)
+ {
+ _seedDataService = seedDataService;
+ _tenantContext = tenantContext;
+ _unitOfWork = unitOfWork;
+ _logger = logger;
+ }
+
+ ///
+ /// Resets the demo company's seed data and redirects back to the dashboard.
+ /// Fails fast if the current company is not the DEMO company.
+ ///
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task ResetDemoData()
+ {
+ var companyId = _tenantContext.GetCurrentCompanyId();
+ if (companyId == null)
+ {
+ TempData["ErrorMessage"] = "Unable to determine current company.";
+ return RedirectToAction("Index", "CompanySettings");
+ }
+
+ // Safety gate: only the DEMO company can use this action
+ var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
+ if (company == null || company.CompanyCode != "DEMO")
+ {
+ TempData["ErrorMessage"] = "Demo reset is only available for the DEMO company.";
+ return RedirectToAction("Index", "CompanySettings");
+ }
+
+ try
+ {
+ var removeOptions = new RemoveSeedDataOptions
+ {
+ Customers = true,
+ InventoryItems = true,
+ Equipment = true,
+ Catalog = true,
+ Bills = true,
+ Expenses = true,
+ Workers = false,
+ Vendors = true,
+ NamedOvens = true,
+ Appointments = true,
+ ForceRemoveAll = true,
+ };
+
+ var removeResult = await _seedDataService.RemoveSeedDataAsync(companyId.Value, removeOptions);
+ if (!removeResult.Success)
+ {
+ TempData["ErrorMessage"] = $"Demo reset failed during wipe: {removeResult.Message}";
+ return RedirectToAction("Index", "CompanySettings");
+ }
+
+ var seedResult = await _seedDataService.SeedCompanyDataAsync(companyId.Value);
+
+ TempData["SuccessMessage"] = $"Demo data reset complete — {seedResult.ItemsSeeded} records refreshed with today’s dates.";
+
+ if (seedResult.Warnings.Any())
+ TempData["WarningMessage"] = $"{seedResult.Warnings.Count} item(s) skipped during reseed (check Platform › Seed Data for details).";
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error resetting demo data for company {CompanyId}", companyId);
+ TempData["ErrorMessage"] = $"Demo reset encountered an error: {ex.Message}";
+ }
+
+ return RedirectToAction("Index", "CompanySettings");
+ }
+}
diff --git a/src/PowderCoating.Web/Controllers/ReportsController.cs b/src/PowderCoating.Web/Controllers/ReportsController.cs
index b628af2..a676264 100644
--- a/src/PowderCoating.Web/Controllers/ReportsController.cs
+++ b/src/PowderCoating.Web/Controllers/ReportsController.cs
@@ -220,12 +220,13 @@ public class ReportsController : Controller
// Top customers by revenue
var topCustomers = completedJobs
.Where(j => j.Customer != null)
- .GroupBy(j => new { j.Customer!.Id, j.Customer.CompanyName })
+ .GroupBy(j => j.Customer!.Id)
.Select(g => new TopCustomerItem
{
- Id = g.Key.Id,
- Name = g.Key.CompanyName,
- Revenue = g.Sum(j => j.FinalPrice),
+ Id = g.Key,
+ Name = g.First().Customer!.CompanyName
+ ?? $"{g.First().Customer!.ContactFirstName} {g.First().Customer!.ContactLastName}".Trim(),
+ Revenue = g.Sum(j => j.FinalPrice),
JobCount = g.Count()
})
.OrderByDescending(x => x.Revenue)
@@ -447,7 +448,9 @@ public class ReportsController : Controller
.SelectMany(i => i.Payments.Where(p => !p.IsDeleted).Select(p => new RecentPaymentItem
{
InvoiceNumber = i.InvoiceNumber,
- CustomerName = i.Customer?.CompanyName ?? i.Customer?.ContactLastName ?? string.Empty,
+ CustomerName = i.Customer?.CompanyName
+ ?? $"{i.Customer?.ContactFirstName} {i.Customer?.ContactLastName}".Trim()
+ ?? string.Empty,
Amount = p.Amount,
PaymentMethod = p.PaymentMethod.ToString(),
PaymentDate = p.PaymentDate
@@ -1360,8 +1363,15 @@ public class ReportsController : Controller
monthlyRevenue.Add(jobsByMonth.TryGetValue(ms, out var mj) ? mj.Sum(j => j.FinalPrice) : 0m);
}
var topCustomers = completedJobs.Where(j => j.Customer != null)
- .GroupBy(j => new { j.Customer!.Id, j.Customer.CompanyName })
- .Select(g => new TopCustomerItem { Id = g.Key.Id, Name = g.Key.CompanyName, Revenue = g.Sum(j => j.FinalPrice), JobCount = g.Count() })
+ .GroupBy(j => j.Customer!.Id)
+ .Select(g => new TopCustomerItem
+ {
+ Id = g.Key,
+ Name = g.First().Customer!.CompanyName
+ ?? $"{g.First().Customer!.ContactFirstName} {g.First().Customer!.ContactLastName}".Trim(),
+ Revenue = g.Sum(j => j.FinalPrice),
+ JobCount = g.Count()
+ })
.OrderByDescending(x => x.Revenue).Take(5).ToList();
var jobsByStatus = jobs.GroupBy(j => j.JobStatus.DisplayName)
.OrderBy(g => jobs.First(j => j.JobStatus.DisplayName == g.Key).JobStatus.DisplayOrder)
@@ -1415,8 +1425,15 @@ public class ReportsController : Controller
.OrderBy(g => completedJobs.First(j => j.JobPriority.DisplayName == g.Key).JobPriority.DisplayOrder)
.ToDictionary(g => g.Key, g => g.Sum(j => j.FinalPrice));
var topCustomers = completedJobs.Where(j => j.Customer != null)
- .GroupBy(j => new { j.Customer!.Id, j.Customer.CompanyName })
- .Select(g => new TopCustomerItem { Id = g.Key.Id, Name = g.Key.CompanyName, Revenue = g.Sum(j => j.FinalPrice), JobCount = g.Count() })
+ .GroupBy(j => j.Customer!.Id)
+ .Select(g => new TopCustomerItem
+ {
+ Id = g.Key,
+ Name = g.First().Customer!.CompanyName
+ ?? $"{g.First().Customer!.ContactFirstName} {g.First().Customer!.ContactLastName}".Trim(),
+ Revenue = g.Sum(j => j.FinalPrice),
+ JobCount = g.Count()
+ })
.OrderByDescending(x => x.Revenue).Take(10).ToList();
return View(new RevenueTrendsViewModel
{
@@ -1508,8 +1525,17 @@ public class ReportsController : Controller
var customersWithMultiple = completedJobs.Where(j => j.Customer != null).GroupBy(j => j.Customer!.Id).Count(g => g.Count() > 1);
var totalWithJobs = completedJobs.Where(j => j.Customer != null).Select(j => j.Customer!.Id).Distinct().Count();
var retentionRate = totalWithJobs > 0 ? Math.Round((decimal)customersWithMultiple / totalWithJobs * 100, 1) : 0m;
- var clv = completedJobs.Where(j => j.Customer != null).GroupBy(j => new { j.Customer!.Id, j.Customer.CompanyName })
- .Select(g => new CustomerLifetimeValueItem { CustomerName = g.Key.CompanyName, TotalRevenue = g.Sum(j => j.FinalPrice), JobCount = g.Count(), AvgOrderValue = g.Average(j => j.FinalPrice), FirstJobDate = g.Min(j => j.CreatedAt), LastJobDate = g.Max(j => j.CompletedDate ?? j.UpdatedAt ?? j.CreatedAt) })
+ var clv = completedJobs.Where(j => j.Customer != null).GroupBy(j => j.Customer!.Id)
+ .Select(g => new CustomerLifetimeValueItem
+ {
+ CustomerName = g.First().Customer!.CompanyName
+ ?? $"{g.First().Customer!.ContactFirstName} {g.First().Customer!.ContactLastName}".Trim(),
+ TotalRevenue = g.Sum(j => j.FinalPrice),
+ JobCount = g.Count(),
+ AvgOrderValue = g.Average(j => j.FinalPrice),
+ FirstJobDate = g.Min(j => j.CreatedAt),
+ LastJobDate = g.Max(j => j.CompletedDate ?? j.UpdatedAt ?? j.CreatedAt)
+ })
.OrderByDescending(c => c.TotalRevenue).Take(10).ToList();
var quotesByStatus = quotes.GroupBy(q => q.QuoteStatus.DisplayName).OrderBy(g => quotes.First(q => q.QuoteStatus.DisplayName == g.Key).QuoteStatus.DisplayOrder).ToDictionary(g => g.Key, g => g.Count());
var quoteFunnel = new QuoteConversionFunnel { Draft = quotes.Count(q => q.QuoteStatus.StatusCode == "DRAFT"), Sent = quotes.Count(q => q.QuoteStatus.StatusCode == "SENT"), Approved = quotes.Count(q => q.QuoteStatus.StatusCode == "APPROVED"), Converted = quotes.Count(q => q.QuoteStatus.StatusCode == "CONVERTED"), Rejected = quotes.Count(q => q.QuoteStatus.StatusCode == "REJECTED"), Expired = quotes.Count(q => q.QuoteStatus.StatusCode == "EXPIRED") };
@@ -1549,7 +1575,7 @@ public class ReportsController : Controller
var agingBuckets = new List { new() { Label = "Current (0–30 days)" }, new() { Label = "31–60 days" }, new() { Label = "61–90 days" }, new() { Label = "Over 90 days" } };
foreach (var inv in outstandingInvoices) { var days = inv.DueDate.HasValue ? (int)(today - inv.DueDate.Value).TotalDays : 0; var balance = inv.BalanceDue; var b = days <= 30 ? 0 : days <= 60 ? 1 : days <= 90 ? 2 : 3; agingBuckets[b].Amount += balance; agingBuckets[b].Count++; }
- var recentPayments = activeInvoices.SelectMany(i => i.Payments.Where(p => !p.IsDeleted).Select(p => new RecentPaymentItem { InvoiceNumber = i.InvoiceNumber, CustomerName = i.Customer?.CompanyName ?? i.Customer?.ContactLastName ?? string.Empty, Amount = p.Amount, PaymentMethod = p.PaymentMethod.ToString(), PaymentDate = p.PaymentDate })).OrderByDescending(p => p.PaymentDate).Take(10).ToList();
+ var recentPayments = activeInvoices.SelectMany(i => i.Payments.Where(p => !p.IsDeleted).Select(p => new RecentPaymentItem { InvoiceNumber = i.InvoiceNumber, CustomerName = i.Customer?.CompanyName ?? $"{i.Customer?.ContactFirstName} {i.Customer?.ContactLastName}".Trim(), Amount = p.Amount, PaymentMethod = p.PaymentMethod.ToString(), PaymentDate = p.PaymentDate })).OrderByDescending(p => p.PaymentDate).Take(10).ToList();
var topOutstanding = outstandingInvoices.Where(i => i.Customer != null).GroupBy(i => new { i.CustomerId, Name = i.Customer!.CompanyName ?? $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim() }).Select(g => new OutstandingCustomerItem { CustomerName = g.Key.Name, OutstandingBalance = g.Sum(i => i.BalanceDue), OpenInvoiceCount = g.Count() }).OrderByDescending(x => x.OutstandingBalance).Take(5).ToList();
var paidWithDates = allInvoices.Where(i => i.Status == InvoiceStatus.Paid && i.SentDate.HasValue && i.PaidDate.HasValue).ToList();
var avgDays = paidWithDates.Any() ? paidWithDates.Average(i => (i.PaidDate!.Value - i.SentDate!.Value).TotalDays) : 0.0;
diff --git a/src/PowderCoating.Web/Views/CompanySettings/Index.cshtml b/src/PowderCoating.Web/Views/CompanySettings/Index.cshtml
index a9cffca..5e19082 100644
--- a/src/PowderCoating.Web/Views/CompanySettings/Index.cshtml
+++ b/src/PowderCoating.Web/Views/CompanySettings/Index.cshtml
@@ -2679,6 +2679,34 @@
+@if (ViewBag.IsDemoCompany == true)
+{
+
+
+
+
+ Demo Environment
+
+
+
+ This is the DEMO company. Use the button below to wipe and re-seed all
+ demo data with fresh dates. Workers and system configuration are preserved.
+
+
+ Reset takes 10–30 seconds. You will be redirected here when complete.
+