From 6eb7be01933bee0b6c459162c0f16cf4fc9a5c76 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Fri, 12 Jun 2026 09:26:40 -0400 Subject: [PATCH] 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 --- .../Services/FinancialReportService.cs | 11 +- .../Services/SeedDataService.ClockEntries.cs | 138 ++++++ .../Services/SeedDataService.Customers.cs | 99 +++-- .../Services/SeedDataService.Extra.cs | 411 ++++++++++++++++++ .../SeedDataService.JobStatusHistory.cs | 187 ++++---- .../Services/SeedDataService.Remove.cs | 18 + .../Services/SeedDataService.Workers.cs | 105 +++-- .../Services/SeedDataService.cs | 8 +- .../Controllers/CompanySettingsController.cs | 2 + .../Controllers/DemoController.cs | 96 ++++ .../Controllers/ReportsController.cs | 50 ++- .../Views/CompanySettings/Index.cshtml | 28 ++ .../Views/Shared/_Layout.cshtml | 31 +- 13 files changed, 963 insertions(+), 221 deletions(-) create mode 100644 src/PowderCoating.Infrastructure/Services/SeedDataService.ClockEntries.cs create mode 100644 src/PowderCoating.Infrastructure/Services/SeedDataService.Extra.cs create mode 100644 src/PowderCoating.Web/Controllers/DemoController.cs 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. +

+
+ @Html.AntiForgeryToken() + +
+
+
+
+} + @section Scripts { -@if (_isNonProd) +@if (_isNonProd && !_isDemoCompany) {