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 <noreply@anthropic.com>
This commit is contained in:
@@ -71,6 +71,11 @@ public class CompanyListDto
|
|||||||
public bool WizardCompleted { get; set; }
|
public bool WizardCompleted { get; set; }
|
||||||
public DateTime? WizardCompletedAt { get; set; }
|
public DateTime? WizardCompletedAt { get; set; }
|
||||||
public string? WizardCompletedByName { 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; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -9,12 +9,17 @@ public record CompanyWizardInfo(bool Completed, DateTime? CompletedAt, string? C
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Per-company entity count summary used to populate the Index list without N+1 round-trips.
|
/// 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
|
||||||
|
/// <c>ChurnRisk</c> badge without a separate round-trip.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record CompanyCountSummary(
|
public record CompanyCountSummary(
|
||||||
IReadOnlyDictionary<int, int> JobCounts,
|
IReadOnlyDictionary<int, int> JobCounts,
|
||||||
IReadOnlyDictionary<int, int> QuoteCounts,
|
IReadOnlyDictionary<int, int> QuoteCounts,
|
||||||
IReadOnlyDictionary<int, int> CustomerCounts,
|
IReadOnlyDictionary<int, int> CustomerCounts,
|
||||||
IReadOnlyDictionary<int, CompanyWizardInfo> WizardInfo
|
IReadOnlyDictionary<int, CompanyWizardInfo> WizardInfo,
|
||||||
|
IReadOnlyDictionary<int, int> Jobs30Counts,
|
||||||
|
IReadOnlyDictionary<int, int> Jobs90Counts,
|
||||||
|
IReadOnlyDictionary<int, DateTime?> LastLoginDates
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ public class CompanyListService : ICompanyListService
|
|||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<CompanyCountSummary> GetCountSummaryAsync(IReadOnlyList<int> companyIds)
|
public async Task<CompanyCountSummary> GetCountSummaryAsync(IReadOnlyList<int> companyIds)
|
||||||
{
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var d30 = now.AddDays(-30);
|
||||||
|
var d90 = now.AddDays(-90);
|
||||||
|
|
||||||
var jobCounts = await _context.Jobs
|
var jobCounts = await _context.Jobs
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.Where(j => companyIds.Contains(j.CompanyId) && !j.IsDeleted)
|
.Where(j => companyIds.Contains(j.CompanyId) && !j.IsDeleted)
|
||||||
@@ -98,6 +102,32 @@ public class CompanyListService : ICompanyListService
|
|||||||
x => x.CompanyId,
|
x => x.CompanyId,
|
||||||
x => new CompanyWizardInfo(true, x.SetupWizardCompletedAt, x.SetupWizardCompletedByName));
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ public class CompaniesController : Controller
|
|||||||
{
|
{
|
||||||
var ids = companyDtos.Select(c => c.Id).ToList();
|
var ids = companyDtos.Select(c => c.Id).ToList();
|
||||||
var summary = await _companyList.GetCountSummaryAsync(ids);
|
var summary = await _companyList.GetCountSummaryAsync(ids);
|
||||||
|
var companyById = companies.ToDictionary(c => c.Id);
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
foreach (var dto in companyDtos)
|
foreach (var dto in companyDtos)
|
||||||
{
|
{
|
||||||
@@ -95,6 +97,23 @@ public class CompaniesController : Controller
|
|||||||
dto.WizardCompletedAt = w.CompletedAt;
|
dto.WizardCompletedAt = w.CompletedAt;
|
||||||
dto.WizardCompletedByName = w.CompletedByName;
|
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,
|
.GetByIdAsync(id, ignoreQueryFilters: true,
|
||||||
c => c.Users,
|
c => c.Users,
|
||||||
c => c.Customers,
|
c => c.Customers,
|
||||||
c => c.Jobs);
|
c => c.Jobs,
|
||||||
|
c => c.Preferences!);
|
||||||
|
|
||||||
if (company == null)
|
if (company == null)
|
||||||
{
|
{
|
||||||
@@ -196,6 +216,51 @@ public class CompaniesController : Controller
|
|||||||
ViewBag.PlanConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(
|
ViewBag.PlanConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(
|
||||||
c => c.IsActive, ignoreQueryFilters: true)).OrderBy(c => c.SortOrder).ToList();
|
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);
|
return View(companyDto);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -118,15 +118,12 @@ public class CompanyHealthController : Controller
|
|||||||
var tquotes = totalQuotes.TryGetValue(c.Id, out var tq) ? tq : 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 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
|
var neverActivated = tjobs == 0 && tcust == 0 && tquotes == 0
|
||||||
&& c.CreatedAt < now.AddDays(-7);
|
&& c.CreatedAt < now.AddDays(-7);
|
||||||
|
|
||||||
var riskLevel = neverActivated ? ChurnRisk.NeverActivated
|
var riskLevel = CompanyHealthHelper.ToRiskLevel(score, neverActivated);
|
||||||
: score >= 75 ? ChurnRisk.Healthy
|
|
||||||
: score >= 45 ? ChurnRisk.AtRisk
|
|
||||||
: ChurnRisk.Critical;
|
|
||||||
|
|
||||||
var configHealth = configHealthMap.TryGetValue(c.Id, out var ch)
|
var configHealth = configHealthMap.TryGetValue(c.Id, out var ch)
|
||||||
? ch : new CompanyConfigHealth { CompanyId = c.Id };
|
? ch : new CompanyConfigHealth { CompanyId = c.Id };
|
||||||
@@ -187,112 +184,10 @@ public class CompanyHealthController : Controller
|
|||||||
return View(all);
|
return View(all);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Health score algorithm ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// <para>
|
|
||||||
/// Scoring rules (penalties are cumulative, floor is 0):
|
|
||||||
/// <list type="bullet">
|
|
||||||
/// <item>Disabled account: score immediately set to 0, no further evaluation.</item>
|
|
||||||
/// <item>Subscription expired past the grace period: −50 pts.</item>
|
|
||||||
/// <item>Subscription within grace period: −30 pts.</item>
|
|
||||||
/// <item>Subscription expiring within 7 days: −20 pts; within 14 days: −10 pts.</item>
|
|
||||||
/// <item>Comped companies skip subscription checks entirely.</item>
|
|
||||||
/// <item>Never logged in: −30 pts; no login in 90+ days: −30; 60+d: −20; 30+d: −10.</item>
|
|
||||||
/// <item>No jobs ever: −20 pts; no jobs in last 90 days: −10; no jobs in 30d: −5.</item>
|
|
||||||
/// </list>
|
|
||||||
/// A <c>daysSinceLogin</c> value of −1 means "never logged in" and is distinct
|
|
||||||
/// from "logged in exactly 0 days ago" (i.e. today).
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
private static (int score, List<string> signals) ComputeHealth(
|
|
||||||
PowderCoating.Core.Entities.Company c, int daysSinceLogin,
|
|
||||||
int j30, int j90, int totalJobs, DateTime now)
|
|
||||||
{
|
|
||||||
var score = 100;
|
|
||||||
var signals = new List<string>();
|
|
||||||
|
|
||||||
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 ────────────────────────────────────────────────────────────────
|
// ── View models ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public enum ChurnRisk { Healthy, AtRisk, Critical, NeverActivated }
|
|
||||||
|
|
||||||
public class CompanyHealthDto
|
public class CompanyHealthDto
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
using PowderCoating.Core.Entities;
|
||||||
|
using PowderCoating.Shared.Constants;
|
||||||
|
|
||||||
|
namespace PowderCoating.Web.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Risk bucket for a tenant company, derived from its health score.</summary>
|
||||||
|
public enum ChurnRisk { Healthy, AtRisk, Critical, NeverActivated }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared health-score logic used by both <see cref="CompanyHealthController"/> (dashboard)
|
||||||
|
/// and <see cref="CompaniesController"/> (list + detail badges).
|
||||||
|
/// </summary>
|
||||||
|
public static class CompanyHealthHelper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="CompanyHealthController"/> XML doc for scoring rules.
|
||||||
|
/// </summary>
|
||||||
|
public static (int Score, List<string> Signals) ComputeHealth(
|
||||||
|
Company c, int daysSinceLogin, int j30, int j90, int totalJobs, DateTime now)
|
||||||
|
{
|
||||||
|
var score = 100;
|
||||||
|
var signals = new List<string>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Derives a <see cref="ChurnRisk"/> bucket from a pre-computed score and activity flags.
|
||||||
|
/// </summary>
|
||||||
|
public static ChurnRisk ToRiskLevel(int score, bool neverActivated) =>
|
||||||
|
neverActivated ? ChurnRisk.NeverActivated
|
||||||
|
: score >= 75 ? ChurnRisk.Healthy
|
||||||
|
: score >= 45 ? ChurnRisk.AtRisk
|
||||||
|
: ChurnRisk.Critical;
|
||||||
|
}
|
||||||
@@ -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("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("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("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"))
|
Card("Platform Notifications", "Review platform-level in-app notifications and operational follow-ups.", "PlatformNotifications", "Index", "bi-bell", "Monitor", SubtleBadge("info"))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -96,6 +96,7 @@
|
|||||||
Plan <i class="bi @SortIcon("Plan")"></i>
|
Plan <i class="bi @SortIcon("Plan")"></i>
|
||||||
</a>
|
</a>
|
||||||
</th>
|
</th>
|
||||||
|
<th>Health</th>
|
||||||
<th>Users</th>
|
<th>Users</th>
|
||||||
<th>Setup Wizard</th>
|
<th>Setup Wizard</th>
|
||||||
<th>
|
<th>
|
||||||
@@ -138,6 +139,18 @@
|
|||||||
<td>
|
<td>
|
||||||
<span class="badge @PlanBadge(company.SubscriptionPlan)">@PlanName(company.SubscriptionPlan)</span>
|
<span class="badge @PlanBadge(company.SubscriptionPlan)">@PlanName(company.SubscriptionPlan)</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
@{
|
||||||
|
var (hBadge, hLabel) = company.HealthRisk switch {
|
||||||
|
"Healthy" => ("bg-success-subtle text-success-emphasis border border-success-subtle", "Healthy"),
|
||||||
|
"AtRisk" => ("bg-warning-subtle text-warning-emphasis border border-warning-subtle", "At Risk"),
|
||||||
|
"Critical" => ("bg-danger-subtle text-danger-emphasis border border-danger-subtle", "Critical"),
|
||||||
|
"NeverActivated" => ("bg-secondary-subtle text-secondary-emphasis border border-secondary-subtle", "Never Active"),
|
||||||
|
_ => ("bg-secondary-subtle text-secondary-emphasis border border-secondary-subtle", company.HealthRisk)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
<span class="badge @hBadge" title="Score: @company.HealthScore">@hLabel</span>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge bg-primary rounded-pill">@company.UserCount</span>
|
<span class="badge bg-primary rounded-pill">@company.UserCount</span>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ── Tab persistence via URL hash ──────────────────────────────────────────
|
||||||
|
var hash = window.location.hash;
|
||||||
|
if (hash) {
|
||||||
|
var btn = document.querySelector('#companyDetailTabs button[data-bs-target="' + hash.replace('#', '#pane-') + '"]');
|
||||||
|
if (!btn) btn = document.querySelector('#companyDetailTabs button[data-bs-target="' + hash + '"]');
|
||||||
|
if (btn) bootstrap.Tab.getOrCreateInstance(btn).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('#companyDetailTabs button[data-bs-toggle="tab"]').forEach(function (btn) {
|
||||||
|
btn.addEventListener('shown.bs.tab', function (e) {
|
||||||
|
var target = e.target.getAttribute('data-bs-target').replace('#pane-', '#');
|
||||||
|
history.replaceState(null, '', target);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Reset Data modal ──────────────────────────────────────────────────────
|
||||||
|
(function () {
|
||||||
|
var input = document.getElementById('resetDataConfirmInput');
|
||||||
|
var hidden = document.getElementById('resetDataConfirmHidden');
|
||||||
|
var btn = document.getElementById('btnResetDataConfirm');
|
||||||
|
if (!input) return;
|
||||||
|
input.addEventListener('input', function () {
|
||||||
|
var ok = input.value.trim() === 'DELETE';
|
||||||
|
btn.disabled = !ok;
|
||||||
|
hidden.value = input.value.trim();
|
||||||
|
});
|
||||||
|
document.getElementById('resetDataModal').addEventListener('hidden.bs.modal', function () {
|
||||||
|
input.value = ''; hidden.value = ''; btn.disabled = true;
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ── Hard Delete modal ─────────────────────────────────────────────────────
|
||||||
|
(function () {
|
||||||
|
var input = document.getElementById('hardDeleteConfirmInput');
|
||||||
|
var hidden = document.getElementById('hardDeleteConfirmHidden');
|
||||||
|
var btn = document.getElementById('btnHardDeleteConfirm');
|
||||||
|
if (!input) return;
|
||||||
|
input.addEventListener('input', function () {
|
||||||
|
var ok = input.value.trim() === 'DELETE';
|
||||||
|
btn.disabled = !ok;
|
||||||
|
hidden.value = input.value.trim();
|
||||||
|
});
|
||||||
|
document.getElementById('hardDeleteModal').addEventListener('hidden.bs.modal', function () {
|
||||||
|
input.value = ''; hidden.value = ''; btn.disabled = true;
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ── Reset Password modal ──────────────────────────────────────────────────
|
||||||
|
var resetPasswordModal = document.getElementById('resetPasswordModal');
|
||||||
|
if (resetPasswordModal) {
|
||||||
|
resetPasswordModal.addEventListener('show.bs.modal', function (e) {
|
||||||
|
var btn = e.relatedTarget;
|
||||||
|
document.getElementById('resetUserId').value = btn.getAttribute('data-user-id');
|
||||||
|
document.getElementById('resetUserName').textContent = btn.getAttribute('data-user-name');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── User Details modal (login history) ───────────────────────────────────
|
||||||
|
var offcanvasEl = document.getElementById('userDetailOffcanvas');
|
||||||
|
if (!offcanvasEl) return;
|
||||||
|
var oc = new bootstrap.Modal(offcanvasEl);
|
||||||
|
|
||||||
|
var loading = document.getElementById('oc-loading');
|
||||||
|
var content = document.getElementById('oc-content');
|
||||||
|
var errorDiv = document.getElementById('oc-error');
|
||||||
|
var errorMsg = document.getElementById('oc-error-msg');
|
||||||
|
|
||||||
|
var actionBadgeClass = {
|
||||||
|
'Login': 'bg-success',
|
||||||
|
'Login2FABypassed': 'bg-success',
|
||||||
|
'FailedLogin': 'bg-danger',
|
||||||
|
'LoginDenied': 'bg-warning text-dark',
|
||||||
|
'AccountLockedOut': 'bg-danger',
|
||||||
|
};
|
||||||
|
var actionLabel = {
|
||||||
|
'Login': 'Login',
|
||||||
|
'Login2FABypassed': 'Login (2FA bypassed)',
|
||||||
|
'FailedLogin': 'Failed login',
|
||||||
|
'LoginDenied': 'Login denied',
|
||||||
|
'AccountLockedOut': 'Account locked out',
|
||||||
|
'SelfServiceAccountDeletion': 'Account deleted',
|
||||||
|
};
|
||||||
|
|
||||||
|
function showLoading() {
|
||||||
|
loading.classList.remove('d-none');
|
||||||
|
content.style.display = 'none';
|
||||||
|
errorDiv.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
loading.classList.add('d-none');
|
||||||
|
content.style.display = 'none';
|
||||||
|
errorMsg.textContent = msg;
|
||||||
|
errorDiv.style.display = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(str) {
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUser(data) {
|
||||||
|
var u = data.user;
|
||||||
|
document.getElementById('oc-name').textContent = u.fullName;
|
||||||
|
document.getElementById('oc-fullname').textContent = u.fullName;
|
||||||
|
document.getElementById('oc-email').textContent = u.email;
|
||||||
|
|
||||||
|
var badge = document.getElementById('oc-status-badge');
|
||||||
|
badge.textContent = u.isActive ? 'Active' : 'Inactive';
|
||||||
|
badge.className = 'badge ms-auto ' + (u.isActive ? 'bg-success' : 'bg-danger');
|
||||||
|
|
||||||
|
document.getElementById('oc-role').textContent = u.companyRole ? u.companyRole.replace('Company', '') : '—';
|
||||||
|
document.getElementById('oc-dept').textContent = u.department || '—';
|
||||||
|
document.getElementById('oc-position').textContent = u.position || '—';
|
||||||
|
document.getElementById('oc-phone').textContent = u.phone || '—';
|
||||||
|
document.getElementById('oc-hire').textContent = u.hireDate || '—';
|
||||||
|
document.getElementById('oc-created').textContent = u.createdAt || '—';
|
||||||
|
document.getElementById('oc-lastlogin').textContent = u.lastLoginDate || 'Never';
|
||||||
|
document.getElementById('oc-emailconf').innerHTML = u.emailConfirmed
|
||||||
|
? '<span class="text-success"><i class="bi bi-check-circle-fill me-1"></i>Yes</span>'
|
||||||
|
: '<span class="text-warning"><i class="bi bi-x-circle-fill me-1"></i>No</span>';
|
||||||
|
|
||||||
|
var tbody = document.getElementById('oc-log-body');
|
||||||
|
var noLogs = document.getElementById('oc-no-logs');
|
||||||
|
var logWrap = document.getElementById('oc-log-table-wrap');
|
||||||
|
var countEl = document.getElementById('oc-log-count');
|
||||||
|
var logs = data.loginHistory;
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
if (!logs || logs.length === 0) {
|
||||||
|
noLogs.style.display = '';
|
||||||
|
logWrap.style.display = 'none';
|
||||||
|
countEl.textContent = '';
|
||||||
|
} else {
|
||||||
|
noLogs.style.display = 'none';
|
||||||
|
logWrap.style.display = '';
|
||||||
|
countEl.textContent = '(' + logs.length + ')';
|
||||||
|
logs.forEach(function (log) {
|
||||||
|
var bc = actionBadgeClass[log.action] || 'bg-secondary';
|
||||||
|
var label = actionLabel[log.action] || log.action;
|
||||||
|
var note = log.note ? '<br><span class="text-muted">' + escHtml(log.note) + '</span>' : '';
|
||||||
|
var tr = document.createElement('tr');
|
||||||
|
tr.innerHTML =
|
||||||
|
'<td class="text-nowrap">' + escHtml(log.timestamp) + '</td>' +
|
||||||
|
'<td><span class="badge ' + bc + '">' + escHtml(label) + '</span>' + note + '</td>' +
|
||||||
|
'<td class="text-nowrap text-muted">' + escHtml(log.ipAddress) + '</td>';
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.classList.add('d-none');
|
||||||
|
content.style.display = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.user-row').forEach(function (row) {
|
||||||
|
row.addEventListener('click', function () {
|
||||||
|
showLoading();
|
||||||
|
oc.show();
|
||||||
|
fetch('/Companies/UserLoginHistory?companyId=' +
|
||||||
|
encodeURIComponent(row.dataset.companyId) +
|
||||||
|
'&userId=' + encodeURIComponent(row.dataset.userId))
|
||||||
|
.then(function (r) {
|
||||||
|
if (!r.ok) throw new Error('Server returned ' + r.status);
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(renderUser)
|
||||||
|
.catch(function (err) { showError('Failed to load user data. ' + err.message); });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user