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:
@@ -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)
|
||||
|
||||
@@ -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 — {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");
|
||||
}
|
||||
}
|
||||
@@ -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<AgingBucketItem> { 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;
|
||||
|
||||
@@ -2679,6 +2679,34 @@
|
||||
</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–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…';">
|
||||
@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 {
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
|
||||
@@ -22,9 +22,10 @@
|
||||
var _bsTheme = (_pclSurface == "ink") ? "dark" : "light";
|
||||
var hasProfilePic = User.FindFirst("HasProfilePicture")?.Value == "true";
|
||||
|
||||
var _envName = _hostEnv.EnvironmentName; // Development, Staging, Production, etc.
|
||||
var _isNonProd = !_hostEnv.IsProduction();
|
||||
var _envColor = _envName.ToLower() switch
|
||||
var _envName = _hostEnv.EnvironmentName; // Development, Staging, Production, etc.
|
||||
var _isNonProd = !_hostEnv.IsProduction();
|
||||
bool _isDemoCompany = false; // suppresses env banner for the demo tenant
|
||||
var _envColor = _envName.ToLower() switch
|
||||
{
|
||||
"development" => "#b45309", // amber-700
|
||||
"staging" => "#7c3aed", // violet-700
|
||||
@@ -53,8 +54,9 @@
|
||||
var company = await UnitOfWork.Companies.GetByIdAsync(impersonatedId.Value, ignoreQueryFilters: true);
|
||||
if (company != null)
|
||||
{
|
||||
companyHasLogo = !string.IsNullOrEmpty(company.LogoFilePath) || (company.LogoData?.Length > 0);
|
||||
logoVersion = (company.UpdatedAt ?? DateTime.UtcNow).Ticks;
|
||||
companyHasLogo = !string.IsNullOrEmpty(company.LogoFilePath) || (company.LogoData?.Length > 0);
|
||||
logoVersion = (company.UpdatedAt ?? DateTime.UtcNow).Ticks;
|
||||
_isDemoCompany = company.CompanyCode == "DEMO";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,10 +73,14 @@
|
||||
if (companyId.HasValue && companyId.Value > 0)
|
||||
{
|
||||
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;
|
||||
logoVersion = (company.UpdatedAt ?? DateTime.UtcNow).Ticks;
|
||||
_isDemoCompany = company.CompanyCode == "DEMO";
|
||||
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);
|
||||
if (company != null)
|
||||
{
|
||||
companyName = company.CompanyName;
|
||||
companyName = company.CompanyName;
|
||||
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
|
||||
{
|
||||
@@ -153,7 +160,7 @@
|
||||
--sidebar-hover: rgba(255,255,255,0.07);
|
||||
--primary-color: #4f46e5;
|
||||
--primary-hover: #4338ca;
|
||||
--env-banner-height: @(_isNonProd ? "32px" : "0px");
|
||||
--env-banner-height: @((_isNonProd && !_isDemoCompany) ? "32px" : "0px");
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -942,7 +949,7 @@
|
||||
<script>
|
||||
(function(){var s='@_serverNavMode',p=localStorage.getItem('pcl-nav-mode')||'ops';document.documentElement.dataset.navMode=s==='fin'?'fin':p;})();
|
||||
</script>
|
||||
@if (_isNonProd)
|
||||
@if (_isNonProd && !_isDemoCompany)
|
||||
{
|
||||
<div style="position:fixed;top:0;left:0;width:100%;height:var(--env-banner-height);z-index:2000;
|
||||
background:@_envColor;color:#fff;
|
||||
|
||||
Reference in New Issue
Block a user