From 726bebdce9edfb62c45332c86f3691f1d6a2699c Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Tue, 12 May 2026 22:22:14 -0400 Subject: [PATCH] Consolidate company admin screens: health badge on list, tabbed detail page Companies/Index: - Added Health badge column (Healthy / At Risk / Critical / Never Active) with the numeric score in a tooltip; computed from the same signals as CompanyHealth/Index using the new shared CompanyHealthHelper Companies/Details: - Converted flat card layout to five tabs: Overview, Users, Subscription, Onboarding, Health; URL hash is preserved so the active tab survives page refresh and back navigation - Subscription tab shows plan/status/dates with an expiry countdown and a "Manage Subscription & Features" button to the full Manage page - Onboarding tab shows wizard completion, milestone progress bar, and first-activity dates (previously only on the standalone page) - Health tab shows score gauge, risk badge, and individual risk signals with a link through to the full CompanyHealth dashboard - JS moved to wwwroot/js/companies-details.js (avoids inline-script failures) Infrastructure: - Extracted ComputeHealth / ToRiskLevel / ChurnRisk to CompanyHealthHelper.cs (same Controllers namespace); CompanyHealthController delegates to it - CompanyCountSummary extended with Jobs30Counts, Jobs90Counts, LastLoginDates (3 extra GROUP BY queries scoped to the current page IDs, not all companies) - CompanyListDto gains HealthScore, HealthRisk, LastLoginDate Navigation: - Removed "Onboarding Progress" hub card from People & Activity; the data is now surfaced directly on the Companies/Details Onboarding tab Co-Authored-By: Claude Sonnet 4.6 --- .../DTOs/Company/CompanyDtos.cs | 5 + .../Services/ICompanyListService.cs | 7 +- .../Services/CompanyListService.cs | 32 +- .../Controllers/CompaniesController.cs | 75 +- .../Controllers/CompanyHealthController.cs | 109 +- .../Controllers/CompanyHealthHelper.cs | 105 ++ .../Controllers/PlatformAdminController.cs | 1 - .../Views/Companies/Details.cshtml | 1188 ++++++++--------- .../Views/Companies/Index.cshtml | 13 + .../wwwroot/js/companies-details.js | 174 +++ 10 files changed, 966 insertions(+), 743 deletions(-) create mode 100644 src/PowderCoating.Web/Controllers/CompanyHealthHelper.cs create mode 100644 src/PowderCoating.Web/wwwroot/js/companies-details.js diff --git a/src/PowderCoating.Application/DTOs/Company/CompanyDtos.cs b/src/PowderCoating.Application/DTOs/Company/CompanyDtos.cs index 2e25b2c..788a025 100644 --- a/src/PowderCoating.Application/DTOs/Company/CompanyDtos.cs +++ b/src/PowderCoating.Application/DTOs/Company/CompanyDtos.cs @@ -71,6 +71,11 @@ public class CompanyListDto public bool WizardCompleted { get; set; } public DateTime? WizardCompletedAt { get; set; } public string? WizardCompletedByName { get; set; } + + // Health signals — populated by CompaniesController.Index after the count summary query + public int HealthScore { get; set; } + public string HealthRisk { get; set; } = "Healthy"; + public DateTime? LastLoginDate { get; set; } } /// diff --git a/src/PowderCoating.Core/Interfaces/Services/ICompanyListService.cs b/src/PowderCoating.Core/Interfaces/Services/ICompanyListService.cs index afe7632..37785d3 100644 --- a/src/PowderCoating.Core/Interfaces/Services/ICompanyListService.cs +++ b/src/PowderCoating.Core/Interfaces/Services/ICompanyListService.cs @@ -9,12 +9,17 @@ public record CompanyWizardInfo(bool Completed, DateTime? CompletedAt, string? C /// /// Per-company entity count summary used to populate the Index list without N+1 round-trips. +/// Also carries health-signal data (jobs30, jobs90, last login) so callers can compute a +/// ChurnRisk badge without a separate round-trip. /// public record CompanyCountSummary( IReadOnlyDictionary JobCounts, IReadOnlyDictionary QuoteCounts, IReadOnlyDictionary CustomerCounts, - IReadOnlyDictionary WizardInfo + IReadOnlyDictionary WizardInfo, + IReadOnlyDictionary Jobs30Counts, + IReadOnlyDictionary Jobs90Counts, + IReadOnlyDictionary LastLoginDates ); /// diff --git a/src/PowderCoating.Infrastructure/Services/CompanyListService.cs b/src/PowderCoating.Infrastructure/Services/CompanyListService.cs index 0da7e5c..55b3967 100644 --- a/src/PowderCoating.Infrastructure/Services/CompanyListService.cs +++ b/src/PowderCoating.Infrastructure/Services/CompanyListService.cs @@ -67,6 +67,10 @@ public class CompanyListService : ICompanyListService /// public async Task GetCountSummaryAsync(IReadOnlyList companyIds) { + var now = DateTime.UtcNow; + var d30 = now.AddDays(-30); + var d90 = now.AddDays(-90); + var jobCounts = await _context.Jobs .IgnoreQueryFilters() .Where(j => companyIds.Contains(j.CompanyId) && !j.IsDeleted) @@ -98,6 +102,32 @@ public class CompanyListService : ICompanyListService x => x.CompanyId, x => new CompanyWizardInfo(true, x.SetupWizardCompletedAt, x.SetupWizardCompletedByName)); - return new CompanyCountSummary(jobCounts, quoteCounts, customerCounts, wizardInfo); + var jobs30 = await _context.Jobs + .IgnoreQueryFilters() + .Where(j => companyIds.Contains(j.CompanyId) && !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 _context.Jobs + .IgnoreQueryFilters() + .Where(j => companyIds.Contains(j.CompanyId) && !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 lastLoginRaw = await _context.Users + .IgnoreQueryFilters() + .Where(u => companyIds.Contains(u.CompanyId) && u.LastLoginDate != null) + .GroupBy(u => u.CompanyId) + .Select(g => new { CompanyId = g.Key, Last = g.Max(u => u.LastLoginDate) }) + .ToListAsync(); + + var lastLogins = lastLoginRaw.ToDictionary( + x => x.CompanyId, + x => x.Last); + + return new CompanyCountSummary(jobCounts, quoteCounts, customerCounts, wizardInfo, + jobs30, jobs90, lastLogins); } } diff --git a/src/PowderCoating.Web/Controllers/CompaniesController.cs b/src/PowderCoating.Web/Controllers/CompaniesController.cs index 2db07b0..09d683f 100644 --- a/src/PowderCoating.Web/Controllers/CompaniesController.cs +++ b/src/PowderCoating.Web/Controllers/CompaniesController.cs @@ -82,19 +82,38 @@ public class CompaniesController : Controller { var ids = companyDtos.Select(c => c.Id).ToList(); var summary = await _companyList.GetCountSummaryAsync(ids); + var companyById = companies.ToDictionary(c => c.Id); + var now = DateTime.UtcNow; foreach (var dto in companyDtos) { - dto.JobCount = summary.JobCounts.GetValueOrDefault(dto.Id, 0); - dto.QuoteCount = summary.QuoteCounts.GetValueOrDefault(dto.Id, 0); + dto.JobCount = summary.JobCounts.GetValueOrDefault(dto.Id, 0); + dto.QuoteCount = summary.QuoteCounts.GetValueOrDefault(dto.Id, 0); dto.CustomerCount = summary.CustomerCounts.GetValueOrDefault(dto.Id, 0); if (summary.WizardInfo.TryGetValue(dto.Id, out var w)) { - dto.WizardCompleted = true; - dto.WizardCompletedAt = w.CompletedAt; + dto.WizardCompleted = true; + dto.WizardCompletedAt = w.CompletedAt; dto.WizardCompletedByName = w.CompletedByName; } + + // Health badge + var lastLogin = summary.LastLoginDates.TryGetValue(dto.Id, out var ll) ? ll : null; + var daysSince = lastLogin.HasValue ? (int)(now - lastLogin.Value).TotalDays : -1; + var j30 = summary.Jobs30Counts.GetValueOrDefault(dto.Id, 0); + var j90 = summary.Jobs90Counts.GetValueOrDefault(dto.Id, 0); + + if (companyById.TryGetValue(dto.Id, out var co)) + { + var (score, _) = CompanyHealthHelper.ComputeHealth(co, daysSince, j30, j90, dto.JobCount, now); + var neverActivated = dto.JobCount == 0 && dto.CustomerCount == 0 && dto.QuoteCount == 0 + && dto.CreatedAt < now.AddDays(-7); + dto.HealthScore = score; + dto.HealthRisk = CompanyHealthHelper.ToRiskLevel(score, neverActivated).ToString(); + } + + dto.LastLoginDate = lastLogin; } } @@ -183,7 +202,8 @@ public class CompaniesController : Controller .GetByIdAsync(id, ignoreQueryFilters: true, c => c.Users, c => c.Customers, - c => c.Jobs); + c => c.Jobs, + c => c.Preferences!); if (company == null) { @@ -196,6 +216,51 @@ public class CompaniesController : Controller ViewBag.PlanConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync( c => c.IsActive, ignoreQueryFilters: true)).OrderBy(c => c.SortOrder).ToList(); + // Health data + var summary = await _companyList.GetCountSummaryAsync(new[] { id }); + var now = DateTime.UtcNow; + var lastLogin = summary.LastLoginDates.TryGetValue(id, out var ll) ? ll : null; + var daysSince = lastLogin.HasValue ? (int)(now - lastLogin.Value).TotalDays : -1; + var j30 = summary.Jobs30Counts.GetValueOrDefault(id, 0); + var j90 = summary.Jobs90Counts.GetValueOrDefault(id, 0); + var totalJobs = companyDto.JobCount; + var totalCust = companyDto.CustomerCount; + var totalQuotes = summary.QuoteCounts.GetValueOrDefault(id, 0); + + var (healthScore, healthSignals) = CompanyHealthHelper.ComputeHealth(company, daysSince, j30, j90, totalJobs, now); + var neverActivated = totalJobs == 0 && totalCust == 0 && totalQuotes == 0 + && company.CreatedAt < now.AddDays(-7); + var riskLevel = CompanyHealthHelper.ToRiskLevel(healthScore, neverActivated); + + ViewBag.HealthScore = healthScore; + ViewBag.HealthRisk = riskLevel.ToString(); + ViewBag.HealthSignals = healthSignals; + ViewBag.Jobs30 = j30; + ViewBag.Jobs90 = j90; + ViewBag.LastLoginDate = lastLogin; + + // Onboarding data (from Preferences) + var prefs = company.Preferences; + int steps = 0; + if (prefs?.FirstJobCreatedAt.HasValue == true || prefs?.FirstQuoteCreatedAt.HasValue == true) steps++; + if (prefs?.FirstInvoiceCreatedAt.HasValue == true) steps++; + if (prefs?.FirstWorkflowCompletedAt.HasValue == true) steps++; + + ViewBag.Onboarding = new PowderCoating.Web.ViewModels.Platform.OnboardingProgressRowViewModel + { + CompanyId = company.Id, + CompanyName = company.CompanyName ?? "", + WizardCompleted = prefs?.SetupWizardCompleted ?? false, + OnboardingPath = prefs?.OnboardingPath, + StepsCompleted = steps, + TotalSteps = 3, + FirstJobCreatedAt = prefs?.FirstJobCreatedAt, + FirstQuoteCreatedAt = prefs?.FirstQuoteCreatedAt, + FirstInvoiceCreatedAt = prefs?.FirstInvoiceCreatedAt, + FirstWorkflowCompletedAt = prefs?.FirstWorkflowCompletedAt, + GuidedActivationDismissedAt = prefs?.GuidedActivationDismissedAt, + }; + return View(companyDto); } catch (Exception ex) diff --git a/src/PowderCoating.Web/Controllers/CompanyHealthController.cs b/src/PowderCoating.Web/Controllers/CompanyHealthController.cs index fe544a7..feb3ede 100644 --- a/src/PowderCoating.Web/Controllers/CompanyHealthController.cs +++ b/src/PowderCoating.Web/Controllers/CompanyHealthController.cs @@ -118,15 +118,12 @@ public class CompanyHealthController : Controller 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) = ComputeHealth(c, daysSince, j30v, j90v, tjobs, now); + 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 = neverActivated ? ChurnRisk.NeverActivated - : score >= 75 ? ChurnRisk.Healthy - : score >= 45 ? ChurnRisk.AtRisk - : ChurnRisk.Critical; + var riskLevel = CompanyHealthHelper.ToRiskLevel(score, neverActivated); var configHealth = configHealthMap.TryGetValue(c.Id, out var ch) ? ch : new CompanyConfigHealth { CompanyId = c.Id }; @@ -187,112 +184,10 @@ public class CompanyHealthController : Controller return View(all); } - // ── Health score algorithm ────────────────────────────────────────────────── - - /// - /// Computes a 0–100 health score and a list of human-readable risk signals for a - /// single company based on its subscription status, login recency, and job activity. - /// - /// Scoring rules (penalties are cumulative, floor is 0): - /// - /// Disabled account: score immediately set to 0, no further evaluation. - /// Subscription expired past the grace period: −50 pts. - /// Subscription within grace period: −30 pts. - /// Subscription expiring within 7 days: −20 pts; within 14 days: −10 pts. - /// Comped companies skip subscription checks entirely. - /// Never logged in: −30 pts; no login in 90+ days: −30; 60+d: −20; 30+d: −10. - /// No jobs ever: −20 pts; no jobs in last 90 days: −10; no jobs in 30d: −5. - /// - /// A daysSinceLogin value of −1 means "never logged in" and is distinct - /// from "logged in exactly 0 days ago" (i.e. today). - /// - /// - private static (int score, List signals) ComputeHealth( - PowderCoating.Core.Entities.Company c, int daysSinceLogin, - int j30, int j90, int totalJobs, DateTime now) - { - var score = 100; - var signals = new List(); - - if (!c.IsActive) - { - signals.Add("Account disabled"); - return (0, signals); - } - - // Subscription health (skip for comped) - if (!c.IsComped && c.SubscriptionEndDate.HasValue) - { - var daysUntil = (int)(c.SubscriptionEndDate.Value.Date - now.Date).TotalDays; - if (daysUntil < -AppConstants.SubscriptionConstants.GracePeriodDays) - { - score -= 50; - signals.Add("Subscription expired"); - } - else if (daysUntil < 0) - { - score -= 30; - signals.Add("In grace period"); - } - else if (daysUntil <= 7) - { - score -= 20; - signals.Add($"Expires in {daysUntil}d"); - } - else if (daysUntil <= 14) - { - score -= 10; - signals.Add($"Expires in {daysUntil}d"); - } - } - - // Login activity - if (daysSinceLogin == -1) - { - score -= 30; - signals.Add("Never logged in"); - } - else if (daysSinceLogin >= 90) - { - score -= 30; - signals.Add($"No login {daysSinceLogin}d"); - } - else if (daysSinceLogin >= 60) - { - score -= 20; - signals.Add($"No login {daysSinceLogin}d"); - } - else if (daysSinceLogin >= 30) - { - score -= 10; - signals.Add($"No login {daysSinceLogin}d"); - } - - // Job activity - if (totalJobs == 0) - { - score -= 20; - signals.Add("No jobs ever"); - } - else if (j90 == 0) - { - score -= 10; - signals.Add("No jobs in 90d"); - } - else if (j30 == 0) - { - score -= 5; - signals.Add("No jobs in 30d"); - } - - return (Math.Max(0, score), signals); - } } // ── View models ──────────────────────────────────────────────────────────────── -public enum ChurnRisk { Healthy, AtRisk, Critical, NeverActivated } - public class CompanyHealthDto { public int Id { get; set; } diff --git a/src/PowderCoating.Web/Controllers/CompanyHealthHelper.cs b/src/PowderCoating.Web/Controllers/CompanyHealthHelper.cs new file mode 100644 index 0000000..503177f --- /dev/null +++ b/src/PowderCoating.Web/Controllers/CompanyHealthHelper.cs @@ -0,0 +1,105 @@ +using PowderCoating.Core.Entities; +using PowderCoating.Shared.Constants; + +namespace PowderCoating.Web.Controllers; + +/// Risk bucket for a tenant company, derived from its health score. +public enum ChurnRisk { Healthy, AtRisk, Critical, NeverActivated } + +/// +/// Shared health-score logic used by both (dashboard) +/// and (list + detail badges). +/// +public static class CompanyHealthHelper +{ + /// + /// Computes a 0–100 health score and a list of human-readable risk signals for a single + /// company based on its subscription status, login recency, and job activity. + /// See XML doc for scoring rules. + /// + public static (int Score, List Signals) ComputeHealth( + Company c, int daysSinceLogin, int j30, int j90, int totalJobs, DateTime now) + { + var score = 100; + var signals = new List(); + + if (!c.IsActive) + { + signals.Add("Account disabled"); + return (0, signals); + } + + if (!c.IsComped && c.SubscriptionEndDate.HasValue) + { + var daysUntil = (int)(c.SubscriptionEndDate.Value.Date - now.Date).TotalDays; + if (daysUntil < -AppConstants.SubscriptionConstants.GracePeriodDays) + { + score -= 50; + signals.Add("Subscription expired"); + } + else if (daysUntil < 0) + { + score -= 30; + signals.Add("In grace period"); + } + else if (daysUntil <= 7) + { + score -= 20; + signals.Add($"Expires in {daysUntil}d"); + } + else if (daysUntil <= 14) + { + score -= 10; + signals.Add($"Expires in {daysUntil}d"); + } + } + + if (daysSinceLogin == -1) + { + score -= 30; + signals.Add("Never logged in"); + } + else if (daysSinceLogin >= 90) + { + score -= 30; + signals.Add($"No login {daysSinceLogin}d"); + } + else if (daysSinceLogin >= 60) + { + score -= 20; + signals.Add($"No login {daysSinceLogin}d"); + } + else if (daysSinceLogin >= 30) + { + score -= 10; + signals.Add($"No login {daysSinceLogin}d"); + } + + if (totalJobs == 0) + { + score -= 20; + signals.Add("No jobs ever"); + } + else if (j90 == 0) + { + score -= 10; + signals.Add("No jobs in 90d"); + } + else if (j30 == 0) + { + score -= 5; + signals.Add("No jobs in 30d"); + } + + return (Math.Max(0, score), signals); + } + + /// + /// Derives a bucket from a pre-computed score and activity flags. + /// + public static ChurnRisk ToRiskLevel(int score, bool neverActivated) => + neverActivated ? ChurnRisk.NeverActivated + : score >= 75 ? ChurnRisk.Healthy + : score >= 45 ? ChurnRisk.AtRisk + : ChurnRisk.Critical; +} diff --git a/src/PowderCoating.Web/Controllers/PlatformAdminController.cs b/src/PowderCoating.Web/Controllers/PlatformAdminController.cs index e4094e5..8315765 100644 --- a/src/PowderCoating.Web/Controllers/PlatformAdminController.cs +++ b/src/PowderCoating.Web/Controllers/PlatformAdminController.cs @@ -48,7 +48,6 @@ public class PlatformAdminController : Controller Card("Platform Users", "Manage SuperAdmin accounts and review platform-user access details.", "PlatformUsers", "Index", "bi-people-fill", "Daily", SubtleBadge("primary")), Card("User Activity", "Review cross-tenant usage history, filters, and behavioral trends.", "UserActivity", "Index", "bi-person-lines-fill", "Review", SubtleBadge("success")), Card("Online Now", "Check who is currently active in the application right now.", "UserActivity", "Online", "bi-broadcast-pin", "Live", SubtleBadge("success")), - Card("Onboarding Progress", "Track which companies are still working through setup and activation milestones.", "OnboardingProgress", "Index", "bi-rocket-takeoff", "Support", SubtleBadge("warning")), Card("Platform Notifications", "Review platform-level in-app notifications and operational follow-ups.", "PlatformNotifications", "Index", "bi-bell", "Monitor", SubtleBadge("info")) } }; diff --git a/src/PowderCoating.Web/Views/Companies/Details.cshtml b/src/PowderCoating.Web/Views/Companies/Details.cshtml index 0985aa9..3f6c9fd 100644 --- a/src/PowderCoating.Web/Views/Companies/Details.cshtml +++ b/src/PowderCoating.Web/Views/Companies/Details.cshtml @@ -1,4 +1,6 @@ @model PowderCoating.Application.DTOs.Company.CompanyDto +@using PowderCoating.Web.ViewModels.Platform +@using PowderCoating.Web.Controllers @{ ViewData["Title"] = Model.CompanyName; @@ -10,299 +12,228 @@ var planNames = planConfigs.ToDictionary(c => c.Plan, c => c.DisplayName); string PlanBadge(int plan) => planBadgeColors.TryGetValue(plan, out var c) ? c : "bg-secondary"; string PlanName(int plan) => planNames.TryGetValue(plan, out var n) ? n : plan.ToString(); + + var healthScore = (int)(ViewBag.HealthScore ?? 0); + var healthRisk = (string)(ViewBag.HealthRisk ?? "Healthy"); + var healthSignals = (List)(ViewBag.HealthSignals ?? new List()); + var jobs30 = (int)(ViewBag.Jobs30 ?? 0); + var jobs90 = (int)(ViewBag.Jobs90 ?? 0); + var lastLogin = (DateTime?)(ViewBag.LastLoginDate); + var onboarding = (OnboardingProgressRowViewModel?)(ViewBag.Onboarding); + + string ScoreColor(int s) => s >= 75 ? "text-success" : s >= 45 ? "text-warning" : "text-danger"; + string RiskBadgeClass(string r) => r switch { + "Healthy" => "bg-success", + "AtRisk" => "bg-warning text-dark", + "Critical" => "bg-danger", + "NeverActivated" => "bg-secondary", + _ => "bg-secondary" + }; + string RiskLabel(string r) => r switch { + "Healthy" => "Healthy", + "AtRisk" => "At Risk", + "Critical" => "Critical", + "NeverActivated" => "Never Activated", + _ => r + }; }
-
+
+ + Companies + @if (!string.IsNullOrEmpty(Model.CompanyCode)) { @Model.CompanyCode } - @if (Model.IsActive) - { - Active - } - else - { - Inactive - } + @(Model.IsActive ? "Active" : "Inactive") + @RiskLabel(healthRisk)
-
@if (TempData["Warning"] != null) +
+ + @if (TempData["Warning"] != null) { -