Demo reset + dev banner suppression for DEMO company

- DemoController: company-code-gated reset action (DEMO only, CSRF protected)
- SeedDataService.Remove: FK-safe topological pre-sweep, all deletes scoped to companyId
- SeedDataService: clock entries, extra seed data, updated customer/worker/job-status seeders
- CompanySettingsController + Index.cshtml: Reset Demo Data button for DEMO company users
- ReportsController + FinancialReportService: supporting report fixes
- _Layout.cshtml: suppress env banner when current company is DEMO (all auth paths)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 09:26:40 -04:00
parent 7735fe3cce
commit 6eb7be0193
13 changed files with 963 additions and 221 deletions
@@ -755,17 +755,20 @@ public class FinancialReportService : IFinancialReportService
var asOfEnd = asOf.AddDays(1).AddTicks(-1); var asOfEnd = asOf.AddDays(1).AddTicks(-1);
var companyName = await GetCompanyNameAsync(companyId); 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) .Include(b => b.Vendor)
.Where(b => b.CompanyId == companyId .Where(b => b.CompanyId == companyId
&& b.Status != BillStatus.Draft && b.Status != BillStatus.Draft
&& b.Status != BillStatus.Voided && b.Status != BillStatus.Voided
&& b.Status != BillStatus.Paid && b.Status != BillStatus.Paid
&& b.BillDate <= asOfEnd && b.BillDate <= asOfEnd)
&& b.BalanceDue > 0)
.OrderBy(b => b.Vendor!.CompanyName) .OrderBy(b => b.Vendor!.CompanyName)
.ThenBy(b => b.DueDate) .ThenBy(b => b.DueDate)
.ToListAsync(); .ToListAsync())
.Where(b => b.BalanceDue > 0)
.ToList();
static string AgingBucket(int d) => d switch static string AgingBucket(int d) => d switch
{ {
@@ -0,0 +1,138 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// Day patterns (approximate distribution):
/// &bull; Full day (7:305:00, ~8.5 h paid) — ~55% of weekdays
/// &bull; Late arrival (9:0010:00 AM in, normal out) — ~15%
/// &bull; Early dismissal (normal in, 1:303:00 PM out) — ~12%
/// &bull; Half day (normal in, 12:0012:30 PM out, ~4 h) — ~10%
/// &bull; Day off (no entry generated) — ~8%
/// </para>
/// <para>
/// Idempotency: returns 0 immediately if any clock entries already exist for
/// this company.
/// </para>
/// </remarks>
private async Task<int> SeedEmployeeClockEntriesAsync(Company company)
{
var existingCount = await _context.Set<EmployeeClockEntry>()
.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<EmployeeClockEntry>();
// 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:3012:44 UTC
int outMinuteUtc = 22 * 60 + (hash % 20); // 22:0022: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:3016:59 UTC = 11:30 AM11:59 AM EST
noteText = halfDayNotes[hash % halfDayNotes.Length];
}
else if (hash < 33)
{
// ~15% late arrival — arrive 9:0010:30 AM EST (14:0015:30 UTC)
inMinuteUtc = 14 * 60 + (hash % 90); // 14:0015:29 UTC
noteText = lateNotes[hash % lateNotes.Length];
}
else if (hash < 45)
{
// ~12% early dismissal — leave 1:303:00 PM EST (18:3020:00 UTC)
outMinuteUtc = 18 * 60 + 30 + (hash % 90); // 18:3019: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<EmployeeClockEntry>().AddRangeAsync(entries);
await _context.SaveChangesAsync();
return entries.Count;
}
}
@@ -6,27 +6,29 @@ namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService public partial class SeedDataService
{ {
/// <summary> /// <summary>
/// Seeds customers at a rate of 15 per calendar month from January of the current year /// Seeds customers at a random rate of 2-16 per calendar month across a date window that makes
/// through the current month, mimicking a real shop that acquires customers steadily /// the shop look like an ongoing business regardless of when the reset happens:
/// as the year progresses. Seeding in January produces 15 customers; seeding in June /// <list type="bullet">
/// produces 90 (15 &times; 6 months). /// <item>April through December reset: window starts January 1 of the current year.</item>
/// <item>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.</item>
/// </list>
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// The first 27 customers are always the hand-crafted anchor accounts (10 commercial, /// The first 27 customers are always the hand-crafted anchor accounts (10 commercial,
/// 17 individual) inserted in a deterministic, index-stable order. <see cref="SeedJobsAsync"/> /// 17 individual) inserted in a deterministic, index-stable order. <see cref="SeedJobsAsync"/>
/// maps customer indices 09 to specific commercial price profiles, so the commercial /// maps customer indices 0&ndash;9 to specific commercial price profiles, so the commercial
/// anchors must always occupy the lowest database IDs for this company. /// anchors must always occupy the lowest database IDs for this company.
/// </para> /// </para>
/// <para> /// <para>
/// Remaining slots in each month (15 anchors_that_month) are filled with procedurally /// Anchors are spread evenly across the full window so that commercial accounts appear as
/// generated individual customers drawn from NC-area name and city pools, so the /// established relationships. Procedural individual customers fill remaining slots to reach
/// New Customers per Month chart shows a consistent 15-bar pattern regardless of /// each month's random target.
/// the reseed date.
/// </para> /// </para>
/// <para> /// <para>
/// Idempotency: returns 0 immediately if any non-deleted customers already exist for /// 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).
/// </para> /// </para>
/// </remarks> /// </remarks>
/// <param name="company">The tenant company to seed customers for.</param> /// <param name="company">The tenant company to seed customers for.</param>
@@ -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("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("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("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("Chris", "Lee", "clee@email.com", "(984) 242-5252", "Raleigh", "NC", "27610", "Custom BMX frame &mdash; Candy Red"),
Indiv("Amanda", "Garcia", "agarcia@email.com", "(919) 353-6363", "Clayton", "NC", "27520", "Motorcycle frame and forks Flat Black"), Indiv("Amanda", "Garcia", "agarcia@email.com", "(919) 353-6363", "Clayton", "NC", "27520", "Motorcycle frame and forks &mdash; Flat Black"),
Indiv("Kevin", "Martinez", "kmartinez@email.com", "(919) 464-7474", "Wendell", "NC", "27591", "Snowmobile frame and tunnel"), 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("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("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 &mdash; Gloss Black"),
}; };
// Spread anchors evenly from Jan 1 of this year through the current month so the // ── Date window ───────────────────────────────────────────────────────
// New Customers chart shows them distributed rather than all in one bar. // Post-Q1 (AprDec): start from January 1 of the current year so the full
int currentMonth = now.Month; // year-to-date story is visible in charts.
// Q1 (JanMar): 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++) for (int i = 0; i < anchors.Count; i++)
{ {
int month = 1 + (i * currentMonth / anchors.Count); int slot = i * windowMonths / anchors.Count;
month = Math.Clamp(month, 1, currentMonth); slot = Math.Clamp(slot, 0, windowMonths - 1);
int day = 3 + (i % 20); var baseDate = windowStart.AddMonths(slot);
anchors[i].CreatedAt = new DateTime(now.Year, month, day, 8, 0, 0, DateTimeKind.Utc); 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). // Insert anchors in deterministic order (commercial first, then individual).
@@ -170,23 +202,16 @@ public partial class SeedDataService
"27591", "27520", "27597", "27312", "27546" }; "27591", "27520", "27597", "27312", "27546" };
string[] domains = { "gmail.com", "yahoo.com", "outlook.com", "hotmail.com", "icloud.com", "aol.com" }; string[] domains = { "gmail.com", "yahoo.com", "outlook.com", "hotmail.com", "icloud.com", "aol.com" };
// Count anchors already assigned to each calendar month // Fill each month slot to the target using procedurally generated individual customers.
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).
int genIdx = 0; 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]); int needed = Math.Max(0, monthlyTargets[slot] - anchorsPerSlot[slot]);
var monthStart = new DateTime(now.Year, month, 1, 9, 0, 0, DateTimeKind.Utc); 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 fn = firstNames[genIdx % firstNames.Length];
string ln = lastNames[(genIdx / firstNames.Length) % lastNames.Length]; string ln = lastNames[(genIdx / firstNames.Length) % lastNames.Length];
@@ -194,7 +219,9 @@ public partial class SeedDataService
string zip = zips[genIdx % zips.Length]; string zip = zips[genIdx % zips.Length];
string email = $"{fn.ToLower()}{ln.ToLower()}{genIdx + 1}@{domains[genIdx % domains.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}"; 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 var gen = new Customer
{ {
@@ -209,7 +236,7 @@ public partial class SeedDataService
PaymentTerms = "Due on receipt", PaymentTerms = "Due on receipt",
IsActive = true, IsActive = true,
CompanyId = company.Id, CompanyId = company.Id,
CreatedAt = monthStart.AddDays(day), CreatedAt = monthBase.AddDays(day),
}; };
try try
@@ -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
// ═══════════════════════════════════════════════════════════════════════════
/// <summary>
/// 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&ndash;2 notes;
/// the selection and text are deterministic so every reset produces the same set.
/// </summary>
private async Task<int> SeedJobNotesAsync(Company company)
{
var existingCount = await _context.Set<JobNote>()
.IgnoreQueryFilters()
.CountAsync(n => n.CompanyId == company.Id && !n.IsDeleted);
if (existingCount > 0) return 0;
var jobs = await _context.Set<Job>()
.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&ndash;12pm.",
"Customer approved color sample.",
"Special instructions: no masking on inner bore.",
"Customer will drop off Wednesday morning.",
];
var notes = new List<JobNote>();
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<JobNote>().AddRangeAsync(notes);
await _context.SaveChangesAsync();
return notes.Count;
}
// ═══════════════════════════════════════════════════════════════════════════
// Customer Notes
// ═══════════════════════════════════════════════════════════════════════════
/// <summary>
/// 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.
/// </summary>
private async Task<int> SeedCustomerNotesAsync(Company company)
{
var existingCount = await _context.Set<CustomerNote>()
.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<CustomerNote>();
var now = DateTime.UtcNow;
// Anchor commercial notes
foreach (var (email, note, important) in anchorData)
{
var customer = await _context.Set<Customer>()
.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<Customer>()
.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<CustomerNote>().AddRangeAsync(notes);
await _context.SaveChangesAsync();
return notes.Count;
}
// ═══════════════════════════════════════════════════════════════════════════
// Rework Records
// ═══════════════════════════════════════════════════════════════════════════
/// <summary>
/// Seeds 7&ndash;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.
/// </summary>
private async Task<int> SeedReworkRecordsAsync(Company company)
{
var existingCount = await _context.Set<ReworkRecord>()
.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<JobStatusLookup>()
.IgnoreQueryFilters()
.Where(s => s.CompanyId == company.Id && terminalCodes.Contains(s.StatusCode))
.Select(s => s.Id)
.ToListAsync();
var completedJobs = await _context.Set<Job>()
.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<ReworkRecord>();
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<ReworkRecord>().AddRangeAsync(records);
await _context.SaveChangesAsync();
return records.Count;
}
// ═══════════════════════════════════════════════════════════════════════════
// Deposits
// ═══════════════════════════════════════════════════════════════════════════
/// <summary>
/// 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.
/// </summary>
private async Task<int> SeedDepositsAsync(Company company)
{
var existingCount = await _context.Set<Deposit>()
.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<Job>()
.IgnoreQueryFilters()
.Where(j => j.CompanyId == company.Id && !j.IsDeleted)
.OrderBy(j => j.Id)
.ToListAsync();
var invoicesByJob = await _context.Set<Invoice>()
.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<Account>()
.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<Deposit>();
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<Deposit>().AddRangeAsync(deposits);
await _context.SaveChangesAsync();
return deposits.Count;
}
// ═══════════════════════════════════════════════════════════════════════════
// Bank Reconciliations
// ═══════════════════════════════════════════════════════════════════════════
/// <summary>
/// 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).
/// </summary>
private async Task<int> SeedBankReconciliationsAsync(Company company)
{
var existingCount = await _context.Set<BankReconciliation>()
.IgnoreQueryFilters()
.CountAsync(r => r.CompanyId == company.Id && !r.IsDeleted);
if (existingCount > 0) return 0;
var checkingAccount = await _context.Set<Account>()
.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<BankReconciliation>();
// 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<BankReconciliation>().AddRangeAsync(reconciliations);
await _context.SaveChangesAsync();
return reconciliations.Count;
}
}
@@ -6,157 +6,122 @@ namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService public partial class SeedDataService
{ {
/// <summary> /// <summary>
/// Seeds a plausible status-transition history for every job belonging to the company, /// Seeds <see cref="JobStatusHistory"/> transition records for every completed, delivered,
/// reconstructing the sequence of transitions a job must have passed through to reach /// or ready-for-pickup job so the Job Cycle Time report can calculate time-per-stage data.
/// its current status.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// Idempotency: returns 0 immediately if any non-deleted history rows already exist for /// For each qualifying job the seeder builds a realistic stage sequence:
/// this company. /// PENDING &rarr; IN_PREPARATION &rarr; (SANDBLASTING if any item requires it)
/// &rarr; (MASKING_TAPING if any item requires it) &rarr; CLEANING &rarr; IN_OVEN
/// &rarr; COATING &rarr; CURING &rarr; QUALITY_CHECK &rarr; [terminal status].
/// </para> /// </para>
/// <para> /// <para>
/// The method does not record arbitrary transitions — it follows the canonical 14-step /// 85&thinsp;% of the job's total cycle time (CreatedAt &rarr; CompletedDate) is
/// pipeline array (<c>PENDING → QUOTED → APPROVED → … → DELIVERED</c>) and generates /// distributed across work stages using fixed per-stage weights that reflect realistic
/// one <see cref="JobStatusHistory"/> row per transition step, from <c>PENDING</c> up to /// relative durations (e.g. SANDBLASTING &gt; CLEANING). The remaining 15&thinsp;%
/// and including the job's current status. /// is left as residual "terminal status" time, which surfaces correctly in the report's
/// last-entry formula <c>(job.CompletedDate &minus; last.ChangedDate)</c>.
/// </para> /// </para>
/// <para> /// <para>
/// Terminal side-branch statuses are handled explicitly: /// Idempotency: returns 0 immediately if any history records already exist for
/// <list type="bullet"> /// this company, matching the pattern used by all other partial seeders.
/// <item><c>ON_HOLD</c> — assumed to have reached <c>QUALITY_CHECK</c> before pausing.</item>
/// <item><c>CANCELLED</c> — assumed to have been cancelled from <c>IN_PREPARATION</c>.</item>
/// </list>
/// </para>
/// <para>
/// Transition timestamps are spread ~6 hours apart starting from <c>job.CreatedAt</c>.
/// 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 <c>DateTime.UtcNow</c>.
/// </para>
/// <para>
/// All history rows are batched into a single <c>AddRangeAsync / SaveChangesAsync</c>
/// call for performance, since the total count can be several hundred rows (50 jobs × up
/// to 14 transitions each).
/// </para> /// </para>
/// </remarks> /// </remarks>
/// <param name="company">The tenant company to seed job status history for.</param> /// <param name="company">The tenant company to seed history for.</param>
/// <returns>Total number of history rows inserted, or 0 if already seeded or no jobs exist.</returns> /// <returns>Number of history records inserted, or 0 if already seeded.</returns>
private async Task<int> SeedJobStatusHistoryAsync(Company company) private async Task<int> SeedJobStatusHistoryAsync(Company company)
{ {
var existingCount = await _context.Set<JobStatusHistory>() var existingCount = await _context.Set<JobStatusHistory>()
.IgnoreQueryFilters() .IgnoreQueryFilters()
.CountAsync(h => h.CompanyId == company.Id && !h.IsDeleted); .CountAsync(h => h.CompanyId == company.Id && !h.IsDeleted);
if (existingCount > 0) if (existingCount > 0) return 0;
return 0;
// Load all job status lookups into a code → id map // Only completed-terminal jobs have a meaningful CompletedDate to calculate cycle time.
var statusMap = await _context.Set<JobStatusLookup>() // CANCELLED is excluded — the report cares only about successfully finished work.
.IgnoreQueryFilters() var terminalCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
.Where(s => s.CompanyId == company.Id)
.ToDictionaryAsync(s => s.StatusCode, s => s.Id);
// Load jobs with their current status
var jobs = await _context.Set<Job>() var jobs = await _context.Set<Job>()
.IgnoreQueryFilters() .IgnoreQueryFilters()
.Include(j => j.JobItems)
.Include(j => j.JobStatus) .Include(j => j.JobStatus)
.Where(j => j.CompanyId == company.Id && !j.IsDeleted) .Where(j => j.CompanyId == company.Id && !j.IsDeleted && j.CompletedDate.HasValue)
.OrderBy(j => j.Id)
.ToListAsync(); .ToListAsync();
if (jobs.Count == 0 || statusMap.Count == 0) jobs = jobs.Where(j => terminalCodes.Contains(j.JobStatus.StatusCode)).ToList();
return 0; if (jobs.Count == 0) return 0;
// Ordered pipeline — each status code in the order a job advances through it. var statuses = await _context.Set<JobStatusLookup>()
// ON_HOLD and CANCELLED are terminal side-branches handled separately. .IgnoreQueryFilters()
var pipeline = new[] .Where(s => s.CompanyId == company.Id)
{ .ToDictionaryAsync(s => s.StatusCode, s => s);
"PENDING", "QUOTED", "APPROVED", "IN_PREPARATION",
"SANDBLASTING", "MASKING_TAPING", "CLEANING", "IN_OVEN",
"COATING", "CURING", "QUALITY_CHECK",
"COMPLETED", "READY_FOR_PICKUP", "DELIVERED"
};
var pipelineIndex = pipeline var records = new List<JobStatusHistory>();
.Select((code, idx) => (code, idx))
.ToDictionary(t => t.code, t => t.idx);
var history = new List<JobStatusHistory>();
var now = DateTime.UtcNow;
foreach (var job in jobs) 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. var needsSand = job.JobItems.Any(i => i.RequiresSandblasting);
// For ON_HOLD: assume it came from QUALITY_CHECK before going on hold. var needsMask = job.JobItems.Any(i => i.RequiresMasking);
// For CANCELLED: assume cancelled from APPROVED or IN_PREPARATION.
string[] codesTraversed;
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<string> { "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 "PENDING" => 2.0, // intake / scheduling buffer
codesTraversed = [.. pipeline.Take(pipelineIndex["QUALITY_CHECK"] + 1), "ON_HOLD"]; "IN_PREPARATION" => 1.5, // disassembly, hang, pre-inspect
} "SANDBLASTING" => 2.0, // media blast + blow-off
else if (currentCode == "CANCELLED") "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 if (!statuses.TryGetValue(stages[i], out var fromLookup)) continue;
codesTraversed = ["PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "CANCELLED"]; if (!statuses.TryGetValue(stages[i + 1], out var toLookup)) continue;
}
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];
}
// Spread transition dates backwards from job.CreatedAt. currentDate = currentDate.AddSeconds(workSeconds * workWeights[i] / totalWeight);
// Each step took roughly 48 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;
// Job was created at job.CreatedAt; each transition is spaced ~6h apart records.Add(new JobStatusHistory
// 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
{ {
JobId = job.Id, JobId = job.Id,
FromStatusId = fromId, FromStatusId = fromLookup.Id,
ToStatusId = toId, ToStatusId = toLookup.Id,
ChangedDate = changedDate, ChangedDate = currentDate,
Notes = null,
CompanyId = company.Id, CompanyId = company.Id,
CreatedAt = changedDate CreatedAt = currentDate,
}); });
} }
} }
if (history.Count == 0) return 0; if (records.Count == 0) return 0;
await _context.Set<JobStatusHistory>().AddRangeAsync(history); await _context.Set<JobStatusHistory>().AddRangeAsync(records);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
return records.Count;
return history.Count;
} }
} }
@@ -136,6 +136,12 @@ public partial class SeedDataService
// Tier 1 — pure leaf records (block nothing of their own) // Tier 1 — pure leaf records (block nothing of their own)
await Sweep<JobTimeEntry>(); // FK → Jobs (Cascade by convention — sweep before jobs) await Sweep<JobTimeEntry>(); // FK → Jobs (Cascade by convention — sweep before jobs)
await Sweep<EmployeeClockEntry>(); // RESTRICT → ApplicationUser — must go before workers
await Sweep<JobNote>(); // NO_ACTION → Jobs
await Sweep<CustomerNote>(); // NO_ACTION → Customers
await Sweep<ReworkRecord>(); // NO_ACTION → Jobs
await Sweep<Deposit>(); // NO_ACTION → Customers, Jobs, Invoices, Quotes
await Sweep<BankReconciliation>(); // FK → Accounts (accounts stay, but sweep for clean reset)
await Sweep<OvenBatchItem>(); // NO_ACTION → Jobs, JobItems, JobItemCoats await Sweep<OvenBatchItem>(); // NO_ACTION → Jobs, JobItems, JobItemCoats
await Sweep<PowderUsageLog>(); // NO_ACTION → Jobs, JobItems, JobItemCoats await Sweep<PowderUsageLog>(); // NO_ACTION → Jobs, JobItems, JobItemCoats
await Sweep<InAppNotification>(); // NO_ACTION → Customers, Invoices, Quotes await Sweep<InAppNotification>(); // NO_ACTION → Customers, Invoices, Quotes
@@ -653,6 +659,18 @@ public partial class SeedDataService
if (workerUsers.Any()) 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<EmployeeClockEntry>().IgnoreQueryFilters()
.Where(e => workerUserIds.Contains(e.UserId)).ToListAsync();
if (clockEntries.Any())
{
_context.Set<EmployeeClockEntry>().RemoveRange(clockEntries);
await _context.SaveChangesAsync();
details.Add($"✓ Removed {clockEntries.Count} clock entry/entries");
}
foreach (var wu in workerUsers) foreach (var wu in workerUsers)
await _userManager.DeleteAsync(wu); await _userManager.DeleteAsync(wu);
totalRemoved += workerUsers.Count; totalRemoved += workerUsers.Count;
@@ -7,32 +7,26 @@ namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService public partial class SeedDataService
{ {
/// <summary> /// <summary>
/// Canonical emails of the 5 demo shop workers. Used as fingerprints in RemoveSeedDataAsync /// Canonical emails of the 3 demo employees (2 workers + 1 manager). Used as fingerprints
/// to avoid needing a special "IsSeeded" flag on ApplicationUser. /// in RemoveSeedDataAsync to avoid needing a special "IsSeeded" flag on ApplicationUser.
/// </summary> /// </summary>
internal static readonly string[] SeededWorkerEmails = internal static readonly string[] SeededWorkerEmails =
[ [
"mike.sanders@pcldemo.com", "mike.sanders@pcldemo.com",
"jake.wilson@pcldemo.com", "jake.wilson@pcldemo.com",
"sarah.brooks@pcldemo.com", "sarah.brooks@pcldemo.com",
"tyler.green@pcldemo.com",
"chris.mason@pcldemo.com",
]; ];
/// <summary> /// <summary>
/// Seeds 5 named shop workers as ApplicationUser records for the demo company: /// Seeds 3 named employees as ApplicationUser records for the demo company:
/// Mike Sanders (Coater), Jake Wilson (Sandblaster), Sarah Brooks (Inspector), /// Mike Sanders (Coater, Worker), Jake Wilson (Sandblaster, Worker),
/// Tyler Green (General Worker), and Chris Mason (Shop Lead). /// and Sarah Brooks (Shop Manager, Manager role with broader permissions).
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Workers are ApplicationUser records with CompanyRole = ShopFloor and the /// Workers are seeded before jobs and time entries so that AssignedUserId on Job
/// Employee system role. They are seeded before jobs and time entries so that /// and UserId on JobTimeEntry and EmployeeClockEntry can reference them.
/// AssignedUserId on Job and UserId on JobTimeEntry 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.
/// 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.
/// </remarks> /// </remarks>
private async Task<int> SeedShopWorkersAsync(Company company) private async Task<int> SeedShopWorkersAsync(Company company)
{ {
@@ -40,50 +34,71 @@ public partial class SeedDataService
.AnyAsync(u => SeededWorkerEmails.Contains(u.Email) && u.CompanyId == company.Id); .AnyAsync(u => SeededWorkerEmails.Contains(u.Email) && u.CompanyId == company.Id);
if (anyExists) return 0; if (anyExists) return 0;
const string defaultPassword = "Worker123!"; const string pwd = "Worker123!Demo";
var hireDate = DateTime.UtcNow.AddMonths(-18);
int created = 0; int created = 0;
// (email, firstName, lastName, empNum, position, laborRate) // ── 2 shop workers ────────────────────────────────────────────────────────
var workers = new (string email, string fn, string ln, string emp, string pos, decimal rate)[] 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), ("mike.sanders@pcldemo.com", "Mike", "Sanders", "EMP-001", "Coater", 22.00m),
("jake.wilson@pcldemo.com", "Jake", "Wilson", "EMP-002", "Sandblaster", 20.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 (await _userManager.FindByEmailAsync(email) != null) continue;
if (user != null) continue; var user = new ApplicationUser
user = new ApplicationUser
{ {
UserName = email, UserName = email, Email = email,
Email = email, FirstName = fn, LastName = ln,
FirstName = fn, EmployeeNumber = emp, Position = pos,
LastName = ln,
EmployeeNumber = emp,
Department = "Shop Floor", Department = "Shop Floor",
Position = pos,
LaborCostPerHour = rate, LaborCostPerHour = rate,
EmailConfirmed = true, EmailConfirmed = true, IsActive = true,
HireDate = DateTime.UtcNow.AddMonths(-12), HireDate = hireDate,
IsActive = true,
CompanyId = company.Id, CompanyId = company.Id,
CompanyRole = AppConstants.CompanyRoles.Worker, CompanyRole = AppConstants.CompanyRoles.Worker,
CanManageJobs = true, CanManageJobs = true,
CanViewShopFloor = 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); // ── 1 manager ─────────────────────────────────────────────────────────────
if (result.Succeeded) const string managerEmail = "sarah.brooks@pcldemo.com";
if (await _userManager.FindByEmailAsync(managerEmail) == null)
{
var mgr = new ApplicationUser
{ {
await _userManager.AddToRoleAsync(user, AppConstants.Roles.Employee); UserName = managerEmail, Email = managerEmail,
created++; 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; return created;
@@ -427,6 +427,7 @@ public partial class SeedDataService : ISeedDataService
await RunSeeder("Jobs", details, errors, result, () => SeedJobsAsync(company)); await RunSeeder("Jobs", details, errors, result, () => SeedJobsAsync(company));
await RunSeeder("Job history", details, errors, result, () => SeedJobStatusHistoryAsync(company)); await RunSeeder("Job history", details, errors, result, () => SeedJobStatusHistoryAsync(company));
await RunSeeder("Time entries", details, errors, result, () => SeedJobTimeEntriesAsync(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("Inv. txns", details, errors, result, () => SeedInventoryTransactionsAsync(company));
await RunSeeder("Invoices", details, errors, result, () => SeedInvoicesAsync(company)); await RunSeeder("Invoices", details, errors, result, () => SeedInvoicesAsync(company));
await RunSeeder("AI predictions", details, errors, result, () => SeedAiPredictionsAsync(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(); } 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") if (company.CompanyCode == "DEMO")
{ {
@@ -181,6 +181,8 @@ public class CompanySettingsController : Controller
? (DateTime?)company.BookLockedThrough.Value.ToLocalTime() ? (DateTime?)company.BookLockedThrough.Value.ToLocalTime()
: null; : null;
ViewBag.IsDemoCompany = company.CompanyCode == "DEMO";
return View(dto); return View(dto);
} }
catch (FormatException fex) catch (FormatException fex)
@@ -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;
/// <summary>
/// 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.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public class DemoController : Controller
{
private readonly ISeedDataService _seedDataService;
private readonly ITenantContext _tenantContext;
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<DemoController> _logger;
public DemoController(
ISeedDataService seedDataService,
ITenantContext tenantContext,
IUnitOfWork unitOfWork,
ILogger<DemoController> logger)
{
_seedDataService = seedDataService;
_tenantContext = tenantContext;
_unitOfWork = unitOfWork;
_logger = logger;
}
/// <summary>
/// Resets the demo company's seed data and redirects back to the dashboard.
/// Fails fast if the current company is not the DEMO company.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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 &mdash; {seedResult.ItemsSeeded} records refreshed with today&rsquo;s dates.";
if (seedResult.Warnings.Any())
TempData["WarningMessage"] = $"{seedResult.Warnings.Count} item(s) skipped during reseed (check Platform &rsaquo; 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");
}
}
@@ -220,12 +220,13 @@ public class ReportsController : Controller
// Top customers by revenue // Top customers by revenue
var topCustomers = completedJobs var topCustomers = completedJobs
.Where(j => j.Customer != null) .Where(j => j.Customer != null)
.GroupBy(j => new { j.Customer!.Id, j.Customer.CompanyName }) .GroupBy(j => j.Customer!.Id)
.Select(g => new TopCustomerItem .Select(g => new TopCustomerItem
{ {
Id = g.Key.Id, Id = g.Key,
Name = g.Key.CompanyName, Name = g.First().Customer!.CompanyName
Revenue = g.Sum(j => j.FinalPrice), ?? $"{g.First().Customer!.ContactFirstName} {g.First().Customer!.ContactLastName}".Trim(),
Revenue = g.Sum(j => j.FinalPrice),
JobCount = g.Count() JobCount = g.Count()
}) })
.OrderByDescending(x => x.Revenue) .OrderByDescending(x => x.Revenue)
@@ -447,7 +448,9 @@ public class ReportsController : Controller
.SelectMany(i => i.Payments.Where(p => !p.IsDeleted).Select(p => new RecentPaymentItem .SelectMany(i => i.Payments.Where(p => !p.IsDeleted).Select(p => new RecentPaymentItem
{ {
InvoiceNumber = i.InvoiceNumber, 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, Amount = p.Amount,
PaymentMethod = p.PaymentMethod.ToString(), PaymentMethod = p.PaymentMethod.ToString(),
PaymentDate = p.PaymentDate 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); monthlyRevenue.Add(jobsByMonth.TryGetValue(ms, out var mj) ? mj.Sum(j => j.FinalPrice) : 0m);
} }
var topCustomers = completedJobs.Where(j => j.Customer != null) 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), JobCount = g.Count() }) .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(); .OrderByDescending(x => x.Revenue).Take(5).ToList();
var jobsByStatus = jobs.GroupBy(j => j.JobStatus.DisplayName) var jobsByStatus = jobs.GroupBy(j => j.JobStatus.DisplayName)
.OrderBy(g => jobs.First(j => j.JobStatus.DisplayName == g.Key).JobStatus.DisplayOrder) .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) .OrderBy(g => completedJobs.First(j => j.JobPriority.DisplayName == g.Key).JobPriority.DisplayOrder)
.ToDictionary(g => g.Key, g => g.Sum(j => j.FinalPrice)); .ToDictionary(g => g.Key, g => g.Sum(j => j.FinalPrice));
var topCustomers = completedJobs.Where(j => j.Customer != null) 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), JobCount = g.Count() }) .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(); .OrderByDescending(x => x.Revenue).Take(10).ToList();
return View(new RevenueTrendsViewModel 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 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 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 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 }) var clv = completedJobs.Where(j => j.Customer != null).GroupBy(j => j.Customer!.Id)
.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) }) .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(); .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 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") }; 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<AgingBucketItem> { new() { Label = "Current (030 days)" }, new() { Label = "3160 days" }, new() { Label = "6190 days" }, new() { Label = "Over 90 days" } }; var agingBuckets = new List<AgingBucketItem> { new() { Label = "Current (030 days)" }, new() { Label = "3160 days" }, new() { Label = "6190 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++; } 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 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 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; var avgDays = paidWithDates.Any() ? paidWithDates.Average(i => (i.PaidDate!.Value - i.SentDate!.Value).TotalDays) : 0.0;
@@ -2679,6 +2679,34 @@
</div> </div>
</div> </div>
@if (ViewBag.IsDemoCompany == true)
{
<div class="container-fluid mt-4">
<div class="card border-warning">
<div class="card-header bg-warning bg-opacity-10 d-flex align-items-center gap-2">
<i class="bi bi-arrow-clockwise text-warning fs-5"></i>
<strong>Demo Environment</strong>
</div>
<div class="card-body">
<p class="mb-2">
This is the <strong>DEMO</strong> company. Use the button below to wipe and re-seed all
demo data with fresh dates. Workers and system configuration are preserved.
</p>
<p class="text-muted small mb-3">
Reset takes 10&ndash;30 seconds. You will be redirected here when complete.
</p>
<form asp-controller="Demo" asp-action="ResetDemoData" method="post"
onsubmit="this.querySelector('button').disabled=true; this.querySelector('button').innerHTML='<span class=\'spinner-border spinner-border-sm me-2\'></span>Resetting&hellip;';">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-warning">
<i class="bi bi-arrow-clockwise me-1"></i>Reset Demo Data
</button>
</form>
</div>
</div>
</div>
}
@section Scripts { @section Scripts {
<script> <script>
$(document).ready(function () { $(document).ready(function () {
@@ -22,9 +22,10 @@
var _bsTheme = (_pclSurface == "ink") ? "dark" : "light"; var _bsTheme = (_pclSurface == "ink") ? "dark" : "light";
var hasProfilePic = User.FindFirst("HasProfilePicture")?.Value == "true"; var hasProfilePic = User.FindFirst("HasProfilePicture")?.Value == "true";
var _envName = _hostEnv.EnvironmentName; // Development, Staging, Production, etc. var _envName = _hostEnv.EnvironmentName; // Development, Staging, Production, etc.
var _isNonProd = !_hostEnv.IsProduction(); var _isNonProd = !_hostEnv.IsProduction();
var _envColor = _envName.ToLower() switch bool _isDemoCompany = false; // suppresses env banner for the demo tenant
var _envColor = _envName.ToLower() switch
{ {
"development" => "#b45309", // amber-700 "development" => "#b45309", // amber-700
"staging" => "#7c3aed", // violet-700 "staging" => "#7c3aed", // violet-700
@@ -53,8 +54,9 @@
var company = await UnitOfWork.Companies.GetByIdAsync(impersonatedId.Value, ignoreQueryFilters: true); var company = await UnitOfWork.Companies.GetByIdAsync(impersonatedId.Value, ignoreQueryFilters: true);
if (company != null) if (company != null)
{ {
companyHasLogo = !string.IsNullOrEmpty(company.LogoFilePath) || (company.LogoData?.Length > 0); companyHasLogo = !string.IsNullOrEmpty(company.LogoFilePath) || (company.LogoData?.Length > 0);
logoVersion = (company.UpdatedAt ?? DateTime.UtcNow).Ticks; logoVersion = (company.UpdatedAt ?? DateTime.UtcNow).Ticks;
_isDemoCompany = company.CompanyCode == "DEMO";
} }
} }
} }
@@ -71,10 +73,14 @@
if (companyId.HasValue && companyId.Value > 0) if (companyId.HasValue && companyId.Value > 0)
{ {
var company = await UnitOfWork.Companies.GetByIdAsync(companyId.Value, ignoreQueryFilters: true); var company = await UnitOfWork.Companies.GetByIdAsync(companyId.Value, ignoreQueryFilters: true);
if (company != null && (!string.IsNullOrEmpty(company.LogoFilePath) || (company.LogoData != null && company.LogoData.Length > 0))) if (company != null)
{ {
companyHasLogo = true; _isDemoCompany = company.CompanyCode == "DEMO";
logoVersion = (company.UpdatedAt ?? DateTime.UtcNow).Ticks; if (!string.IsNullOrEmpty(company.LogoFilePath) || (company.LogoData != null && company.LogoData.Length > 0))
{
companyHasLogo = true;
logoVersion = (company.UpdatedAt ?? DateTime.UtcNow).Ticks;
}
} }
} }
} }
@@ -86,9 +92,10 @@
var company = await UnitOfWork.Companies.GetByIdAsync(companyId.Value, ignoreQueryFilters: true); var company = await UnitOfWork.Companies.GetByIdAsync(companyId.Value, ignoreQueryFilters: true);
if (company != null) if (company != null)
{ {
companyName = company.CompanyName; companyName = company.CompanyName;
companyHasLogo = !string.IsNullOrEmpty(company.LogoFilePath) || (company.LogoData != null && company.LogoData.Length > 0); companyHasLogo = !string.IsNullOrEmpty(company.LogoFilePath) || (company.LogoData != null && company.LogoData.Length > 0);
logoVersion = (company.UpdatedAt ?? DateTime.UtcNow).Ticks; logoVersion = (company.UpdatedAt ?? DateTime.UtcNow).Ticks;
_isDemoCompany = company.CompanyCode == "DEMO";
} }
else else
{ {
@@ -153,7 +160,7 @@
--sidebar-hover: rgba(255,255,255,0.07); --sidebar-hover: rgba(255,255,255,0.07);
--primary-color: #4f46e5; --primary-color: #4f46e5;
--primary-hover: #4338ca; --primary-hover: #4338ca;
--env-banner-height: @(_isNonProd ? "32px" : "0px"); --env-banner-height: @((_isNonProd && !_isDemoCompany) ? "32px" : "0px");
} }
* { * {
@@ -942,7 +949,7 @@
<script> <script>
(function(){var s='@_serverNavMode',p=localStorage.getItem('pcl-nav-mode')||'ops';document.documentElement.dataset.navMode=s==='fin'?'fin':p;})(); (function(){var s='@_serverNavMode',p=localStorage.getItem('pcl-nav-mode')||'ops';document.documentElement.dataset.navMode=s==='fin'?'fin':p;})();
</script> </script>
@if (_isNonProd) @if (_isNonProd && !_isDemoCompany)
{ {
<div style="position:fixed;top:0;left:0;width:100%;height:var(--env-banner-height);z-index:2000; <div style="position:fixed;top:0;left:0;width:100%;height:var(--env-banner-height);z-index:2000;
background:@_envColor;color:#fff; background:@_envColor;color:#fff;