2ad6df1195
- Companies list and Company Health now hide Expired/Canceled accounts whose subscription ended 14+ days ago; show/hide toggle via banner - KPI cards on Company Health exclude churned tenants when hidden - showChurned param threads through sort, pagination, search, and filter forms - Powder catalog: fix missing UnitPrice on user-contributed entries; add back-sync to fill catalog gaps on existing matches; wire AiAugmentFromUrl and manual inventory Create into catalog contribute path Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
234 lines
10 KiB
C#
234 lines
10 KiB
C#
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using PowderCoating.Application.DTOs.Health;
|
|
using PowderCoating.Application.Interfaces;
|
|
using PowderCoating.Core.Enums;
|
|
using PowderCoating.Infrastructure.Data;
|
|
using PowderCoating.Shared.Constants;
|
|
|
|
namespace PowderCoating.Web.Controllers;
|
|
|
|
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
|
public class CompanyHealthController : Controller
|
|
{
|
|
private readonly ApplicationDbContext _db;
|
|
private readonly ICompanyConfigHealthService _configHealth;
|
|
|
|
public CompanyHealthController(ApplicationDbContext db, ICompanyConfigHealthService configHealth)
|
|
{
|
|
_db = db;
|
|
_configHealth = configHealth;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renders the Company Health dashboard showing a churn-risk score and engagement
|
|
/// signals for every tenant company (SuperAdmin only).
|
|
/// <para>
|
|
/// Data is collected via a series of focused, aggregated DB queries (one per signal
|
|
/// type) rather than a single join query, because the join approach would require
|
|
/// complex LEFT JOINs across five tables and produce a large intermediate result
|
|
/// set. Each query groups by <c>CompanyId</c> and materialises to a dictionary for
|
|
/// O(1) lookup when building the per-company <see cref="CompanyHealthDto"/>.
|
|
/// </para>
|
|
/// <para>
|
|
/// All queries use <c>IgnoreQueryFilters()</c> so deleted companies and their
|
|
/// data are visible to SuperAdmins. The <c>!c.IsDeleted</c> predicate on the
|
|
/// companies query is then applied manually to exclude companies that have been
|
|
/// permanently removed from the platform.
|
|
/// </para>
|
|
/// <para>
|
|
/// Health scoring and signal classification are delegated to
|
|
/// <see cref="ComputeHealth"/> and the result is used to assign a
|
|
/// <see cref="ChurnRisk"/> bucket. Summary counts (for the dashboard header KPI
|
|
/// cards) are computed from the full un-filtered list <em>before</em> applying the
|
|
/// user's risk/search filters, so the KPI cards always show platform-wide totals.
|
|
/// </para>
|
|
/// </summary>
|
|
public async Task<IActionResult> Index(string? risk, string? search, bool configIssuesOnly = false, bool showChurned = false)
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
var d30 = now.AddDays(-30);
|
|
var d90 = now.AddDays(-90);
|
|
var churnedCutoff = now.AddDays(-14);
|
|
|
|
// One query per signal — all keyed by CompanyId
|
|
var allCompanies = await _db.Companies
|
|
.AsNoTracking().IgnoreQueryFilters()
|
|
.Where(c => !c.IsDeleted)
|
|
.ToListAsync();
|
|
|
|
var churnedCount = allCompanies.Count(c =>
|
|
(c.SubscriptionStatus == SubscriptionStatus.Expired || c.SubscriptionStatus == SubscriptionStatus.Canceled)
|
|
&& c.SubscriptionEndDate.HasValue && c.SubscriptionEndDate.Value < churnedCutoff);
|
|
|
|
var companies = showChurned
|
|
? allCompanies
|
|
: allCompanies.Where(c =>
|
|
!((c.SubscriptionStatus == SubscriptionStatus.Expired || c.SubscriptionStatus == SubscriptionStatus.Canceled)
|
|
&& c.SubscriptionEndDate.HasValue && c.SubscriptionEndDate.Value < churnedCutoff))
|
|
.ToList();
|
|
|
|
var lastLogins = await _db.Users
|
|
.AsNoTracking().IgnoreQueryFilters()
|
|
.Where(u => u.LastLoginDate != null)
|
|
.GroupBy(u => u.CompanyId)
|
|
.Select(g => new { CompanyId = g.Key, Last = g.Max(u => u.LastLoginDate) })
|
|
.ToDictionaryAsync(x => x.CompanyId, x => x.Last);
|
|
|
|
var jobs30 = await _db.Jobs
|
|
.AsNoTracking().IgnoreQueryFilters()
|
|
.Where(j => !j.IsDeleted && j.CreatedAt >= d30)
|
|
.GroupBy(j => j.CompanyId)
|
|
.Select(g => new { g.Key, Count = g.Count() })
|
|
.ToDictionaryAsync(x => x.Key, x => x.Count);
|
|
|
|
var jobs90 = await _db.Jobs
|
|
.AsNoTracking().IgnoreQueryFilters()
|
|
.Where(j => !j.IsDeleted && j.CreatedAt >= d90)
|
|
.GroupBy(j => j.CompanyId)
|
|
.Select(g => new { g.Key, Count = g.Count() })
|
|
.ToDictionaryAsync(x => x.Key, x => x.Count);
|
|
|
|
var totalJobs = await _db.Jobs
|
|
.AsNoTracking().IgnoreQueryFilters()
|
|
.Where(j => !j.IsDeleted)
|
|
.GroupBy(j => j.CompanyId)
|
|
.Select(g => new { g.Key, Count = g.Count() })
|
|
.ToDictionaryAsync(x => x.Key, x => x.Count);
|
|
|
|
var totalCustomers = await _db.Customers
|
|
.AsNoTracking().IgnoreQueryFilters()
|
|
.Where(c => !c.IsDeleted)
|
|
.GroupBy(c => c.CompanyId)
|
|
.Select(g => new { g.Key, Count = g.Count() })
|
|
.ToDictionaryAsync(x => x.Key, x => x.Count);
|
|
|
|
var totalQuotes = await _db.Quotes
|
|
.AsNoTracking().IgnoreQueryFilters()
|
|
.GroupBy(q => q.CompanyId)
|
|
.Select(g => new { g.Key, Count = g.Count() })
|
|
.ToDictionaryAsync(x => x.Key, x => x.Count);
|
|
|
|
var planNames = await _db.SubscriptionPlanConfigs
|
|
.AsNoTracking().IgnoreQueryFilters()
|
|
.Where(p => p.IsActive)
|
|
.ToDictionaryAsync(p => p.Plan, p => p.DisplayName);
|
|
|
|
// Batch config health check — one set of queries for all companies
|
|
var companyIds = companies.Select(c => c.Id).ToList();
|
|
var configHealthMap = await _configHealth.CheckBatchAsync(companyIds);
|
|
|
|
var all = companies.Select(c =>
|
|
{
|
|
var lastLogin = lastLogins.TryGetValue(c.Id, out var ll) ? ll : null;
|
|
var daysSince = lastLogin.HasValue ? (int)(now - lastLogin.Value).TotalDays : -1;
|
|
var j30v = jobs30.TryGetValue(c.Id, out var v30) ? v30 : 0;
|
|
var j90v = jobs90.TryGetValue(c.Id, out var v90) ? v90 : 0;
|
|
var tjobs = totalJobs.TryGetValue(c.Id, out var tj) ? tj : 0;
|
|
var tcust = totalCustomers.TryGetValue(c.Id, out var tc) ? tc : 0;
|
|
var tquotes = totalQuotes.TryGetValue(c.Id, out var tq) ? tq : 0;
|
|
var planName = planNames.TryGetValue(c.SubscriptionPlan, out var pn) ? pn : c.SubscriptionPlan.ToString();
|
|
|
|
var (score, signals) = CompanyHealthHelper.ComputeHealth(c, daysSince, j30v, j90v, tjobs, now);
|
|
|
|
var neverActivated = tjobs == 0 && tcust == 0 && tquotes == 0
|
|
&& c.CreatedAt < now.AddDays(-7);
|
|
|
|
var riskLevel = CompanyHealthHelper.ToRiskLevel(score, neverActivated);
|
|
|
|
var configHealth = configHealthMap.TryGetValue(c.Id, out var ch)
|
|
? ch : new CompanyConfigHealth { CompanyId = c.Id };
|
|
|
|
return new CompanyHealthDto
|
|
{
|
|
Id = c.Id,
|
|
CompanyName = c.CompanyName,
|
|
PrimaryContactEmail = c.PrimaryContactEmail,
|
|
PlanDisplayName = planName,
|
|
SubscriptionStatus = c.SubscriptionStatus,
|
|
SubscriptionEndDate = c.SubscriptionEndDate,
|
|
IsActive = c.IsActive,
|
|
IsComped = c.IsComped,
|
|
IsNeverActivated = neverActivated,
|
|
CreatedAt = c.CreatedAt,
|
|
LastLoginDate = lastLogin,
|
|
DaysSinceLastLogin = daysSince,
|
|
JobsLast30Days = j30v,
|
|
JobsLast90Days = j90v,
|
|
TotalJobs = tjobs,
|
|
TotalCustomers = tcust,
|
|
TotalQuotes = tquotes,
|
|
HealthScore = score,
|
|
RiskLevel = riskLevel,
|
|
RiskSignals = signals,
|
|
ConfigHealth = configHealth,
|
|
};
|
|
}).ToList();
|
|
|
|
// Summary counts before filtering (always platform-wide totals)
|
|
ViewBag.HealthyCount = all.Count(h => h.RiskLevel == ChurnRisk.Healthy);
|
|
ViewBag.AtRiskCount = all.Count(h => h.RiskLevel == ChurnRisk.AtRisk);
|
|
ViewBag.CriticalCount = all.Count(h => h.RiskLevel == ChurnRisk.Critical);
|
|
ViewBag.NeverActivatedCount = all.Count(h => h.RiskLevel == ChurnRisk.NeverActivated);
|
|
ViewBag.ConfigIssuesCount = all.Count(h => !h.ConfigHealth.IsHealthy);
|
|
ViewBag.Risk = risk;
|
|
ViewBag.Search = search;
|
|
ViewBag.ConfigIssuesOnly = configIssuesOnly;
|
|
ViewBag.ShowChurned = showChurned;
|
|
ViewBag.ChurnedCount = churnedCount;
|
|
|
|
if (!string.IsNullOrWhiteSpace(search))
|
|
all = all.Where(h =>
|
|
h.CompanyName.Contains(search, StringComparison.OrdinalIgnoreCase) ||
|
|
h.PrimaryContactEmail.Contains(search, StringComparison.OrdinalIgnoreCase)).ToList();
|
|
|
|
if (!string.IsNullOrWhiteSpace(risk) && Enum.TryParse<ChurnRisk>(risk, out var riskEnum))
|
|
all = all.Where(h => h.RiskLevel == riskEnum).ToList();
|
|
|
|
if (configIssuesOnly)
|
|
all = all.Where(h => !h.ConfigHealth.IsHealthy).ToList();
|
|
|
|
// Worst first
|
|
all = all.OrderBy(h => (int)h.RiskLevel == 3 ? 3 : 0) // NeverActivated last
|
|
.ThenBy(h => h.HealthScore)
|
|
.ThenBy(h => h.CompanyName)
|
|
.ToList();
|
|
|
|
return View(all);
|
|
}
|
|
|
|
}
|
|
|
|
// ── View models ────────────────────────────────────────────────────────────────
|
|
|
|
public class CompanyHealthDto
|
|
{
|
|
public int Id { get; set; }
|
|
public string CompanyName { get; set; } = "";
|
|
public string PrimaryContactEmail { get; set; } = "";
|
|
public string PlanDisplayName { get; set; } = "";
|
|
public SubscriptionStatus SubscriptionStatus { get; set; }
|
|
public DateTime? SubscriptionEndDate { get; set; }
|
|
public bool IsActive { get; set; }
|
|
public bool IsComped { get; set; }
|
|
public bool IsNeverActivated { get; set; }
|
|
public DateTime CreatedAt { get; set; }
|
|
|
|
public DateTime? LastLoginDate { get; set; }
|
|
public int DaysSinceLastLogin { get; set; } // -1 = never
|
|
|
|
public int JobsLast30Days { get; set; }
|
|
public int JobsLast90Days { get; set; }
|
|
public int TotalJobs { get; set; }
|
|
public int TotalCustomers { get; set; }
|
|
public int TotalQuotes { get; set; }
|
|
|
|
public int HealthScore { get; set; }
|
|
public ChurnRisk RiskLevel { get; set; }
|
|
public List<string> RiskSignals { get; set; } = new();
|
|
|
|
// Configuration health (setup gaps that break features)
|
|
public CompanyConfigHealth ConfigHealth { get; set; } = new();
|
|
}
|