Compare commits
8 Commits
4ca90f561e
...
787d1504ef
| Author | SHA1 | Date | |
|---|---|---|---|
| 787d1504ef | |||
| 726bebdce9 | |||
| 786b78e502 | |||
| cb1b6dceb6 | |||
| fb31fa7eb3 | |||
| 637be701ea | |||
| e9cd67f5d9 | |||
| 433090effd |
@@ -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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -9,12 +9,17 @@ public record CompanyWizardInfo(bool Completed, DateTime? CompletedAt, string? C
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
public record CompanyCountSummary(
|
||||
IReadOnlyDictionary<int, int> JobCounts,
|
||||
IReadOnlyDictionary<int, int> QuoteCounts,
|
||||
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>
|
||||
|
||||
Generated
+10594
File diff suppressed because it is too large
Load Diff
+88
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMissingPlatformSettings : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Conditional inserts — safe to run against a DB that already has some of these keys set manually.
|
||||
migrationBuilder.Sql(@"
|
||||
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'SmsEnabled')
|
||||
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
|
||||
VALUES ('SmsEnabled','false','SMS Enabled','Platform-level switch for outbound SMS. When off, no SMS messages are sent regardless of company settings.','Notifications');
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'TrialsEnabled')
|
||||
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
|
||||
VALUES ('TrialsEnabled','true','Trials Enabled','Allow new companies to register with a free trial period. When off, registration requires a paid plan immediately.','Subscriptions');
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'GracePeriodDays')
|
||||
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
|
||||
VALUES ('GracePeriodDays','14','Grace Period (days)','Days after subscription expiry before access is fully cut off. Gives companies time to renew without an abrupt lockout.','Subscriptions');
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'GracePeriodAppliesToTrials')
|
||||
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
|
||||
VALUES ('GracePeriodAppliesToTrials','false','Grace Period Applies to Trials','When enabled, trial companies also receive the grace period after expiry rather than being cut off immediately.','Subscriptions');
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'MaxTenants')
|
||||
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
|
||||
VALUES ('MaxTenants','-1','Max Tenants','Maximum number of active tenant companies allowed on the platform. Set to -1 for no limit.','Subscriptions');
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'AiCatalogPriceCheckEnabled')
|
||||
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
|
||||
VALUES ('AiCatalogPriceCheckEnabled','true','AI Catalog Price Check','Platform-level switch for the AI catalog price review feature. When off, the feature is disabled for all companies regardless of their settings.','AI');
|
||||
");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 1, 34, 45, 450, DateTimeKind.Utc).AddTicks(8377));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 1, 34, 45, 450, DateTimeKind.Utc).AddTicks(8383));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 1, 34, 45, 450, DateTimeKind.Utc).AddTicks(8385));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5837));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5846));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5847));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6574,7 +6574,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5837),
|
||||
CreatedAt = new DateTime(2026, 5, 13, 1, 34, 45, 450, DateTimeKind.Utc).AddTicks(8377),
|
||||
Description = "Standard pricing for regular customers",
|
||||
DiscountPercent = 0m,
|
||||
IsActive = true,
|
||||
@@ -6585,7 +6585,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5846),
|
||||
CreatedAt = new DateTime(2026, 5, 13, 1, 34, 45, 450, DateTimeKind.Utc).AddTicks(8383),
|
||||
Description = "5% discount for preferred customers",
|
||||
DiscountPercent = 5m,
|
||||
IsActive = true,
|
||||
@@ -6596,7 +6596,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5847),
|
||||
CreatedAt = new DateTime(2026, 5, 13, 1, 34, 45, 450, DateTimeKind.Utc).AddTicks(8385),
|
||||
Description = "10% discount for premium customers",
|
||||
DiscountPercent = 10m,
|
||||
IsActive = true,
|
||||
|
||||
@@ -67,6 +67,10 @@ public class CompanyListService : ICompanyListService
|
||||
/// <inheritdoc/>
|
||||
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
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────
|
||||
|
||||
/// <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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
public enum ChurnRisk { Healthy, AtRisk, Critical, NeverActivated }
|
||||
|
||||
public class CompanyHealthDto
|
||||
{
|
||||
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;
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using System.Reflection;
|
||||
using System.Security.Principal;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
[Authorize(Roles = "SuperAdmin,Administrator")]
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public class DiagnosticsController : Controller
|
||||
{
|
||||
private readonly ILogger<DiagnosticsController> _logger;
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace PowderCoating.Web.Controllers;
|
||||
public class PlatformAdminController : Controller
|
||||
{
|
||||
private static readonly bool ShowRawLogFiles = string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WEBSITE_SITE_NAME"));
|
||||
private static readonly bool ShowStorageMigration = string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WEBSITE_SITE_NAME"));
|
||||
|
||||
public IActionResult TenantsBilling() => View(BuildTenantsBillingHub());
|
||||
|
||||
@@ -47,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"))
|
||||
}
|
||||
};
|
||||
@@ -94,21 +94,28 @@ public class PlatformAdminController : Controller
|
||||
};
|
||||
}
|
||||
|
||||
private static PlatformAdminHubViewModel BuildMaintenanceHub() => new()
|
||||
private static PlatformAdminHubViewModel BuildMaintenanceHub()
|
||||
{
|
||||
Title = "Maintenance",
|
||||
PageIcon = "bi-wrench-adjustable-circle",
|
||||
Intro = "Use these tools for exceptional maintenance work, migration tasks, and destructive admin operations. They are not routine day-to-day workflows.",
|
||||
WarningTitle = "Use With Care",
|
||||
WarningMessage = "These tools can expose bulk data, change platform state, or permanently remove records. Use them deliberately and preferably with a written reason or ticket.",
|
||||
Cards = new List<PlatformAdminLinkCardViewModel>
|
||||
var cards = new List<PlatformAdminLinkCardViewModel>
|
||||
{
|
||||
Card("Data Export", "Export a tenant company's data set for audits, offboarding, support, or migration work.", "DataExport", "Index", "bi-file-earmark-arrow-down", "Maintenance", SubtleBadge("warning")),
|
||||
Card("Data Purge", "Permanently delete soft-deleted records after previewing impact and cutoff windows.", "DataPurge", "Index", "bi-trash3", "Dangerous", SubtleBadge("danger")),
|
||||
Card("Storage Migration", "Run one-off migration of local media files into cloud storage.", "StorageMigration", "Index", "bi-cloud-upload", "One-off", SubtleBadge("info")),
|
||||
Card("Seed Data", "Seed or remove system and demo data for setup, QA, or controlled test scenarios.", "SeedData", "Index", "bi-database-fill-gear", "Restricted", SubtleBadge("danger"))
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
if (ShowStorageMigration)
|
||||
cards.Insert(2, Card("Storage Migration", "Run one-off migration of local media files into cloud storage.", "StorageMigration", "Index", "bi-cloud-upload", "One-off", SubtleBadge("info")));
|
||||
|
||||
return new PlatformAdminHubViewModel
|
||||
{
|
||||
Title = "Maintenance",
|
||||
PageIcon = "bi-wrench-adjustable-circle",
|
||||
Intro = "Use these tools for exceptional maintenance work, migration tasks, and destructive admin operations. They are not routine day-to-day workflows.",
|
||||
WarningTitle = "Use With Care",
|
||||
WarningMessage = "These tools can expose bulk data, change platform state, or permanently remove records. Use them deliberately and preferably with a written reason or ticket.",
|
||||
Cards = cards
|
||||
};
|
||||
}
|
||||
|
||||
private static PlatformAdminLinkCardViewModel Card(
|
||||
string title,
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
}
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="mb-2">
|
||||
<a asp-controller="PlatformAdmin" asp-action="Observability" class="text-muted small text-decoration-none">
|
||||
<i class="bi bi-arrow-left me-1"></i>Observability
|
||||
</a>
|
||||
</div>
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<div>
|
||||
<h4 class="mb-0"><i class="bi bi-robot me-2 text-primary"></i>AI Usage Report</h4>
|
||||
|
||||
@@ -37,6 +37,11 @@
|
||||
}
|
||||
|
||||
<div class="container-fluid py-3">
|
||||
<div class="mb-2">
|
||||
<a asp-controller="PlatformAdmin" asp-action="Observability" class="text-muted small text-decoration-none">
|
||||
<i class="bi bi-arrow-left me-1"></i>Observability
|
||||
</a>
|
||||
</div>
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<div>
|
||||
<h4 class="mb-0"><i class="bi bi-shield-check me-2 text-primary"></i>Audit Log</h4>
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
}
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="mb-2">
|
||||
<a asp-controller="PlatformAdmin" asp-action="Observability" class="text-muted small text-decoration-none">
|
||||
<i class="bi bi-arrow-left me-1"></i>Observability
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@* Add new ban form *@
|
||||
<div class="card shadow-sm mb-4">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -96,6 +96,7 @@
|
||||
Plan <i class="bi @SortIcon("Plan")"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th>Health</th>
|
||||
<th>Users</th>
|
||||
<th>Setup Wizard</th>
|
||||
<th>
|
||||
@@ -138,6 +139,18 @@
|
||||
<td>
|
||||
<span class="badge @PlanBadge(company.SubscriptionPlan)">@PlanName(company.SubscriptionPlan)</span>
|
||||
</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>
|
||||
<span class="badge bg-primary rounded-pill">@company.UserCount</span>
|
||||
</td>
|
||||
|
||||
@@ -29,6 +29,11 @@
|
||||
}
|
||||
|
||||
<div class="container-fluid py-3">
|
||||
<div class="mb-2">
|
||||
<a asp-controller="PlatformAdmin" asp-action="Maintenance" class="text-muted small text-decoration-none">
|
||||
<i class="bi bi-arrow-left me-1"></i>Maintenance
|
||||
</a>
|
||||
</div>
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<div>
|
||||
<h4 class="mb-0"><i class="bi bi-file-earmark-arrow-down me-2 text-primary"></i>Data Export</h4>
|
||||
|
||||
@@ -34,6 +34,11 @@
|
||||
}
|
||||
|
||||
<div class="container-fluid py-3">
|
||||
<div class="mb-2">
|
||||
<a asp-controller="PlatformAdmin" asp-action="Maintenance" class="text-muted small text-decoration-none">
|
||||
<i class="bi bi-arrow-left me-1"></i>Maintenance
|
||||
</a>
|
||||
</div>
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<div>
|
||||
<h4 class="mb-0"><i class="bi bi-trash3 me-2 text-danger"></i>Data Purge & Cleanup</h4>
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
}
|
||||
|
||||
<div class="container mt-4">
|
||||
<div class="mb-2">
|
||||
<a asp-controller="PlatformAdmin" asp-action="Observability" class="text-muted small text-decoration-none">
|
||||
<i class="bi bi-arrow-left me-1"></i>Observability
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-6">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@model PowderCoating.Application.DTOs.Common.PagedResult<PowderCoating.Application.DTOs.Notification.NotificationLogDto>
|
||||
@{
|
||||
ViewData["Title"] = "Notification Log";
|
||||
ViewData["Title"] = "Email & SMS Log";
|
||||
ViewData["PageIcon"] = "bi-bell-history";
|
||||
var sortCol = ViewBag.SortColumn as string ?? "SentAt";
|
||||
var sortDir = ViewBag.SortDirection as string ?? "desc";
|
||||
|
||||
@@ -5,147 +5,273 @@
|
||||
ViewData["Title"] = "Platform Settings";
|
||||
ViewData["PageIcon"] = "bi-gear-wide-connected";
|
||||
var groups = Model.GroupBy(s => s.GroupName ?? "General").OrderBy(g => g.Key);
|
||||
|
||||
static string GroupIcon(string group) => group switch
|
||||
{
|
||||
"Notifications" => "bi-bell",
|
||||
"Subscriptions" => "bi-credit-card",
|
||||
"Quotes" => "bi-file-earmark-text",
|
||||
"Data Retention" => "bi-clock-history",
|
||||
"AI" => "bi-robot",
|
||||
_ => "bi-globe"
|
||||
};
|
||||
|
||||
static string GroupDescription(string group) => group switch
|
||||
{
|
||||
"Notifications" => "Controls where platform event alerts (signups, bug reports, billing events) are sent, and whether SMS is active.",
|
||||
"Subscriptions" => "Trial and billing defaults applied to new tenant companies at registration, including grace periods and tenant limits.",
|
||||
"Quotes" => "Default validity windows and token expiry for customer-facing quote links.",
|
||||
"Data Retention" => "How long audit and webhook records are kept before the nightly purge removes them.",
|
||||
"AI" => "Platform-level feature flags for AI-powered features. These override any company-level settings.",
|
||||
_ => "Core platform configuration values used across features and email links."
|
||||
};
|
||||
|
||||
static bool IsBool(string key) =>
|
||||
key.EndsWith("Enabled", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.EndsWith("AppliesToTrials", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.EndsWith("TrialsEnabled", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
static string InputType(string key) => key switch
|
||||
{
|
||||
var k when k.Contains("Email", StringComparison.OrdinalIgnoreCase) => "email",
|
||||
var k when k.Contains("Url", StringComparison.OrdinalIgnoreCase) => "url",
|
||||
var k when k.Contains("Days", StringComparison.OrdinalIgnoreCase) => "number",
|
||||
var k when k.Contains("Max", StringComparison.OrdinalIgnoreCase) => "number",
|
||||
_ => "text"
|
||||
};
|
||||
|
||||
static string InputHint(string key) => key switch
|
||||
{
|
||||
var k when k.Contains("Email", StringComparison.OrdinalIgnoreCase) => "e.g. admin@example.com",
|
||||
var k when k.Contains("Url", StringComparison.OrdinalIgnoreCase) => "e.g. https://app.yourdomain.com",
|
||||
var k when k.Contains("Days", StringComparison.OrdinalIgnoreCase) => "Enter a whole number of days",
|
||||
var k when k.Contains("Max", StringComparison.OrdinalIgnoreCase) => "Enter a whole number, or -1 for unlimited",
|
||||
_ => ""
|
||||
};
|
||||
}
|
||||
|
||||
@section Styles {
|
||||
<style>
|
||||
[data-bs-theme="dark"] a.text-dark { color: var(--bs-body-color) !important; }
|
||||
[data-bs-theme="dark"] .card-header.bg-white,
|
||||
[data-bs-theme="dark"] .settings-group-header { background-color: var(--bs-card-cap-bg) !important; }
|
||||
.settings-group-header { background: var(--bs-body-bg); border-bottom: 1px solid var(--bs-border-color); }
|
||||
.setting-meta { font-size: 0.75rem; color: var(--bs-secondary-color); margin-top: 2px; }
|
||||
</style>
|
||||
}
|
||||
|
||||
<div class="mb-4"></div>
|
||||
<div class="container-fluid py-2">
|
||||
|
||||
@foreach (var group in groups)
|
||||
{
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-white border-0 py-3">
|
||||
<h5 class="mb-0 fw-semibold">
|
||||
<i class="bi bi-sliders me-2 text-primary"></i>@group.Key
|
||||
</h5>
|
||||
@if (TempData["SuccessMessage"] != null)
|
||||
{
|
||||
<div class="alert alert-success alert-permanent alert-dismissible fade show mb-4" role="alert">
|
||||
<i class="bi bi-check-circle-fill me-2"></i>@TempData["SuccessMessage"]
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="ps-4" style="width:30%">Setting</th>
|
||||
<th style="width:45%">Value</th>
|
||||
<th class="text-end pe-4" style="width:25%">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var s in group)
|
||||
{
|
||||
<tr>
|
||||
<td class="ps-4 align-middle">
|
||||
<div class="fw-semibold">@(s.Label ?? s.Key)</div>
|
||||
@if (!string.IsNullOrWhiteSpace(s.Description))
|
||||
{
|
||||
<small class="text-muted">@s.Description</small>
|
||||
}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
@if (string.IsNullOrWhiteSpace(s.Value))
|
||||
{
|
||||
<span class="text-muted fst-italic">Not set</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@s.Value</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-end pe-4 align-middle">
|
||||
<button class="btn btn-sm btn-outline-primary"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#editModal"
|
||||
data-key="@s.Key"
|
||||
data-label="@(s.Label ?? s.Key)"
|
||||
data-value="@s.Value">
|
||||
<i class="bi bi-pencil me-1"></i>Edit
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@foreach (var group in groups)
|
||||
{
|
||||
var groupKey = group.Key;
|
||||
var icon = GroupIcon(groupKey);
|
||||
var desc = GroupDescription(groupKey);
|
||||
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header settings-group-header py-3">
|
||||
<div class="d-flex align-items-start gap-3">
|
||||
<div class="rounded-3 p-2 bg-primary bg-opacity-10 flex-shrink-0">
|
||||
<i class="bi @icon text-primary fs-5"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="mb-0 fw-semibold">@groupKey</h5>
|
||||
<p class="mb-0 small text-muted mt-1">@desc</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile card view — shown on screens < 992px -->
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var s in group)
|
||||
{
|
||||
<div class="mobile-data-card">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon bg-primary"><i class="bi bi-sliders"></i></div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@(s.Label ?? s.Key)</h6>
|
||||
@if (!string.IsNullOrWhiteSpace(s.Description))
|
||||
{
|
||||
<small>@s.Description</small>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Value</span>
|
||||
<span class="mobile-card-value">
|
||||
@if (string.IsNullOrWhiteSpace(s.Value))
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive d-none d-lg-block">
|
||||
<table class="table mb-0">
|
||||
<colgroup>
|
||||
<col style="width:32%">
|
||||
<col style="width:43%">
|
||||
<col style="width:25%">
|
||||
</colgroup>
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="ps-4 fw-semibold">Setting</th>
|
||||
<th class="fw-semibold">Current Value</th>
|
||||
<th class="text-end pe-4 fw-semibold">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var s in group)
|
||||
{
|
||||
<tr>
|
||||
<td class="ps-4 align-middle">
|
||||
<div class="fw-semibold">@(s.Label ?? s.Key)</div>
|
||||
@if (!string.IsNullOrWhiteSpace(s.Description))
|
||||
{
|
||||
<small class="text-muted">@s.Description</small>
|
||||
}
|
||||
<div class="setting-meta font-monospace mt-1 text-muted">@s.Key</div>
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
@if (IsBool(s.Key))
|
||||
{
|
||||
var isOn = string.Equals(s.Value, "true", StringComparison.OrdinalIgnoreCase);
|
||||
<form asp-action="Save" method="post" class="d-inline">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" name="key" value="@s.Key" />
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<input type="radio" class="btn-check" name="value"
|
||||
id="@(s.Key)_yes" value="true"
|
||||
@(isOn ? "checked" : "")
|
||||
onchange="this.form.submit()" />
|
||||
<label class="btn @(isOn ? "btn-success" : "btn-outline-success")" for="@(s.Key)_yes">Yes</label>
|
||||
<input type="radio" class="btn-check" name="value"
|
||||
id="@(s.Key)_no" value="false"
|
||||
@(!isOn ? "checked" : "")
|
||||
onchange="this.form.submit()" />
|
||||
<label class="btn @(!isOn ? "btn-danger" : "btn-outline-danger")" for="@(s.Key)_no">No</label>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
else if (string.IsNullOrWhiteSpace(s.Value))
|
||||
{
|
||||
<span class="text-muted fst-italic">Not set</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
@s.Value
|
||||
<span class="fw-medium">@s.Value</span>
|
||||
}
|
||||
</span>
|
||||
@if (s.UpdatedAt.HasValue)
|
||||
{
|
||||
<div class="setting-meta">
|
||||
Updated @s.UpdatedAt.Value.ToString("MMM d, yyyy")
|
||||
@if (!string.IsNullOrWhiteSpace(s.UpdatedBy))
|
||||
{
|
||||
<span>by @s.UpdatedBy</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td class="text-end pe-4 align-middle">
|
||||
@if (!IsBool(s.Key))
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-primary"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#editModal"
|
||||
data-key="@s.Key"
|
||||
data-label="@(s.Label ?? s.Key)"
|
||||
data-value="@(s.Value ?? "")"
|
||||
data-type="@InputType(s.Key)"
|
||||
data-hint="@InputHint(s.Key)">
|
||||
<i class="bi bi-pencil me-1"></i>Edit
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@* Mobile card view *@
|
||||
<div class="mobile-card-view d-lg-none">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var s in group)
|
||||
{
|
||||
<div class="mobile-data-card">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon bg-primary"><i class="bi @icon"></i></div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@(s.Label ?? s.Key)</h6>
|
||||
@if (!string.IsNullOrWhiteSpace(s.Description))
|
||||
{
|
||||
<small>@s.Description</small>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Key</span>
|
||||
<span class="mobile-card-value text-muted small font-monospace">@s.Key</span>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Value</span>
|
||||
<span class="mobile-card-value">
|
||||
@if (string.IsNullOrWhiteSpace(s.Value))
|
||||
{
|
||||
<span class="text-muted fst-italic">Not set</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
@s.Value
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
@if (s.UpdatedAt.HasValue)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Updated</span>
|
||||
<span class="mobile-card-value text-muted small">
|
||||
@s.UpdatedAt.Value.ToString("MMM d, yyyy")
|
||||
@if (!string.IsNullOrWhiteSpace(s.UpdatedBy)) { <text>by @s.UpdatedBy</text> }
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Key</span>
|
||||
<span class="mobile-card-value text-muted small font-monospace">@s.Key</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<button class="btn btn-sm btn-outline-primary"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#editModal"
|
||||
data-key="@s.Key"
|
||||
data-label="@(s.Label ?? s.Key)"
|
||||
data-value="@(s.Value ?? "")"
|
||||
data-type="@InputType(s.Key)"
|
||||
data-hint="@InputHint(s.Key)">
|
||||
<i class="bi bi-pencil me-1"></i>Edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<button class="btn btn-sm btn-outline-primary"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#editModal"
|
||||
data-key="@s.Key"
|
||||
data-label="@(s.Label ?? s.Key)"
|
||||
data-value="@s.Value">
|
||||
<i class="bi bi-pencil me-1"></i>Edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (!Model.Any())
|
||||
{
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body text-center py-5">
|
||||
<i class="bi bi-sliders fs-1 text-muted opacity-50"></i>
|
||||
<p class="mt-3 text-muted">No platform settings found. Run a database migration to seed defaults.</p>
|
||||
@if (!Model.Any())
|
||||
{
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body text-center py-5">
|
||||
<i class="bi bi-gear-wide-connected fs-1 text-muted opacity-50"></i>
|
||||
<p class="mt-3 text-muted">No platform settings found. Run a database migration to seed defaults.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Edit Modal -->
|
||||
<div class="modal fade" id="editModal" tabindex="-1">
|
||||
<div class="modal fade" id="editModal" tabindex="-1" aria-labelledby="editModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form asp-action="Save" method="post">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" id="editKey" name="key" />
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Edit Setting: <span id="editLabel"></span></h5>
|
||||
<h5 class="modal-title" id="editModalLabel">
|
||||
<i class="bi bi-pencil-square me-2 text-primary"></i>Edit: <span id="editLabel"></span>
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label class="form-label fw-semibold" for="editValue">Value</label>
|
||||
<input type="text" class="form-control" id="editValue" name="value" />
|
||||
<input class="form-control" id="editValue" name="value" />
|
||||
<div class="form-text" id="editHint"></div>
|
||||
<div class="form-text text-muted mt-2">
|
||||
Leave blank to clear the setting and use the built-in default.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
@@ -161,10 +287,25 @@
|
||||
@section Scripts {
|
||||
<script>
|
||||
document.getElementById('editModal').addEventListener('show.bs.modal', function (e) {
|
||||
const btn = e.relatedTarget;
|
||||
document.getElementById('editKey').value = btn.dataset.key;
|
||||
const btn = e.relatedTarget;
|
||||
const type = btn.dataset.type || 'text';
|
||||
const hint = btn.dataset.hint || '';
|
||||
const input = document.getElementById('editValue');
|
||||
|
||||
document.getElementById('editKey').value = btn.dataset.key;
|
||||
document.getElementById('editLabel').textContent = btn.dataset.label;
|
||||
document.getElementById('editValue').value = btn.dataset.value ?? '';
|
||||
document.getElementById('editHint').textContent = hint;
|
||||
|
||||
input.type = type;
|
||||
input.value = btn.dataset.value ?? '';
|
||||
|
||||
if (type === 'number') {
|
||||
input.min = '0';
|
||||
input.step = '1';
|
||||
} else {
|
||||
input.removeAttribute('min');
|
||||
input.removeAttribute('step');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
}
|
||||
|
||||
@@ -18,7 +18,21 @@
|
||||
}
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="mb-4"></div>
|
||||
<div class="mb-2">
|
||||
<a asp-controller="PlatformAdmin" asp-action="Maintenance" class="text-muted small text-decoration-none">
|
||||
<i class="bi bi-arrow-left me-1"></i>Maintenance
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<environment include="Production">
|
||||
<div class="alert alert-danger alert-permanent d-flex gap-3 align-items-start mb-4">
|
||||
<i class="bi bi-exclamation-octagon-fill fs-4 flex-shrink-0 mt-1 text-danger"></i>
|
||||
<div>
|
||||
<div class="fw-semibold">You are running in Production</div>
|
||||
<div class="small mt-1">Seed operations write data directly to the live database. Only proceed if you have a specific, intentional reason — for example, onboarding a new company. Do not seed the default demo company in production.</div>
|
||||
</div>
|
||||
</div>
|
||||
</environment>
|
||||
|
||||
@if (TempData["SuccessMessage"] != null)
|
||||
{
|
||||
|
||||
@@ -1294,6 +1294,15 @@
|
||||
<i class="bi bi-people"></i>
|
||||
<span>People & Activity</span>
|
||||
</a>
|
||||
<a asp-controller="UserActivity" asp-action="Online" class="nav-link">
|
||||
<i class="bi bi-broadcast-pin"></i>
|
||||
<span>Online Now</span>
|
||||
@{ var _onlineCount = OnlineUserTracker.GetActiveCount(); }
|
||||
@if (_onlineCount > 0)
|
||||
{
|
||||
<span class="badge bg-success ms-auto">@_onlineCount</span>
|
||||
}
|
||||
</a>
|
||||
<a asp-controller="PlatformAdmin" asp-action="ContentMessaging" class="nav-link">
|
||||
<i class="bi bi-megaphone"></i>
|
||||
<span>Content & Messaging</span>
|
||||
@@ -1491,7 +1500,7 @@
|
||||
<li><a class="dropdown-item" asp-controller="Tools" asp-action="Index"><i class="bi bi-wrench-adjustable me-2"></i>Tools</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
}
|
||||
<li><a class="dropdown-item" asp-controller="NotificationLogs" asp-action="Index"><i class="bi bi-bell me-2"></i>Notification Log</a></li>
|
||||
<li><a class="dropdown-item" asp-controller="NotificationLogs" asp-action="Index"><i class="bi bi-bell me-2"></i>Email & SMS Log</a></li>
|
||||
<li><a class="dropdown-item" asp-controller="BugReport" asp-action="Submit"><i class="bi bi-bug me-2"></i>Report a Bug</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,21 @@
|
||||
}
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="mb-2">
|
||||
<a asp-controller="PlatformAdmin" asp-action="Maintenance" class="text-muted small text-decoration-none">
|
||||
<i class="bi bi-arrow-left me-1"></i>Maintenance
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<environment include="Production">
|
||||
<div class="alert alert-warning alert-permanent d-flex gap-3 align-items-start mb-4">
|
||||
<i class="bi bi-exclamation-triangle-fill fs-4 flex-shrink-0 mt-1"></i>
|
||||
<div>
|
||||
<div class="fw-semibold">This tool is not needed in Production</div>
|
||||
<div class="small mt-1">The platform has already migrated to Azure Blob Storage. Running this migration again in production is unnecessary and may cause unintended side effects. If you believe there is a specific reason to proceed, verify with the team first.</div>
|
||||
</div>
|
||||
</div>
|
||||
</environment>
|
||||
|
||||
<div class="row g-4">
|
||||
<!-- Status Card -->
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
</style>
|
||||
}
|
||||
|
||||
<div class="mb-2">
|
||||
<a asp-controller="PlatformAdmin" asp-action="Observability" class="text-muted small text-decoration-none">
|
||||
<i class="bi bi-arrow-left me-1"></i>Observability
|
||||
</a>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end align-items-center mb-4">
|
||||
<span class="badge bg-secondary">Read-only diagnostics</span>
|
||||
</div>
|
||||
|
||||
@@ -41,6 +41,11 @@
|
||||
}
|
||||
|
||||
<div class="container-fluid py-3">
|
||||
<div class="mb-2">
|
||||
<a asp-controller="PlatformAdmin" asp-action="Observability" class="text-muted small text-decoration-none">
|
||||
<i class="bi bi-arrow-left me-1"></i>Observability
|
||||
</a>
|
||||
</div>
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<div>
|
||||
<h4 class="mb-0"><i class="bi bi-database-exclamation me-2 text-danger"></i>System Logs</h4>
|
||||
|
||||
@@ -33,6 +33,11 @@
|
||||
}
|
||||
|
||||
<div class="container-fluid py-3">
|
||||
<div class="mb-2">
|
||||
<a asp-controller="PlatformAdmin" asp-action="Observability" class="text-muted small text-decoration-none">
|
||||
<i class="bi bi-arrow-left me-1"></i>Observability
|
||||
</a>
|
||||
</div>
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h4 class="mb-0">
|
||||
<i class="bi bi-speedometer2 me-2 text-primary"></i>Usage & Quota
|
||||
|
||||
@@ -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