a0bdd2b5b4
Replace all corruption variants with HTML entities across 226 view files: - 3-char UTF-8-as-Win1252 sequences (ae-corruption) - Standalone smart/curly quotes that break C# Razor expressions - Partially re-corrupted variants where the 3rd byte was normalised to ASCII tools/Fix-Encoding.ps1: re-runnable sweep; uses [char] code points so the script itself never contains a literal non-ASCII character; supports -DryRun .githooks/pre-commit: blocks commits containing the ae-corruption byte signature (xc3xa2xe2x82xac); git core.hooksPath = .githooks so the hook is repo-committed and active for all future work on this machine. Build clean; 225 unit tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
402 lines
20 KiB
Plaintext
402 lines
20 KiB
Plaintext
@using PowderCoating.Web.Controllers
|
|
@using PowderCoating.Application.DTOs.Health
|
|
@model List<CompanyHealthDto>
|
|
@{
|
|
ViewData["Title"] = "Company Health";
|
|
|
|
var showChurned = (bool)(ViewBag.ShowChurned ?? false);
|
|
var churnedCount = (int)(ViewBag.ChurnedCount ?? 0);
|
|
|
|
string RiskBadge(ChurnRisk r) => r switch {
|
|
ChurnRisk.Healthy => "bg-success",
|
|
ChurnRisk.AtRisk => "bg-warning text-dark",
|
|
ChurnRisk.Critical => "bg-danger",
|
|
ChurnRisk.NeverActivated => "bg-secondary",
|
|
_ => "bg-secondary"
|
|
};
|
|
|
|
string RiskLabel(ChurnRisk r) => r switch {
|
|
ChurnRisk.Healthy => "Healthy",
|
|
ChurnRisk.AtRisk => "At Risk",
|
|
ChurnRisk.Critical => "Critical",
|
|
ChurnRisk.NeverActivated => "Never Activated",
|
|
_ => r.ToString()
|
|
};
|
|
|
|
string RowClass(ChurnRisk r) => r switch {
|
|
ChurnRisk.Critical => "table-danger",
|
|
ChurnRisk.AtRisk => "table-warning",
|
|
ChurnRisk.NeverActivated => "opacity-75",
|
|
_ => ""
|
|
};
|
|
|
|
string ScoreColor(int s) => s >= 75 ? "text-success" : s >= 45 ? "text-warning" : "text-danger";
|
|
|
|
string ConfigBadgeClass(CompanyConfigHealth ch) => ch.IsHealthy ? "bg-success" :
|
|
ch.OverallSeverity == ConfigIssueSeverity.Critical ? "bg-danger" : "bg-warning text-dark";
|
|
|
|
string LoginLabel(int days) => days switch {
|
|
-1 => "Never",
|
|
0 => "Today",
|
|
1 => "Yesterday",
|
|
_ => $"{days}d ago"
|
|
};
|
|
|
|
string LoginClass(int days) => days is -1 or >= 90 ? "text-danger" : days >= 30 ? "text-warning" : "text-success";
|
|
}
|
|
|
|
@section Styles {
|
|
<style>
|
|
[data-bs-theme="dark"] .table-light th,
|
|
[data-bs-theme="dark"] .table-light td {
|
|
background-color: var(--bs-secondary-bg);
|
|
color: var(--bs-body-color);
|
|
}
|
|
[data-bs-theme="dark"] .table-danger td {
|
|
background-color: rgba(220,53,69,.15) !important;
|
|
}
|
|
[data-bs-theme="dark"] .table-warning td {
|
|
background-color: rgba(255,193,7,.1) !important;
|
|
}
|
|
[data-bs-theme="dark"] .card {
|
|
border-color: var(--bs-border-color) !important;
|
|
}
|
|
[data-bs-theme="dark"] .card-footer {
|
|
background-color: var(--bs-secondary-bg);
|
|
border-color: var(--bs-border-color);
|
|
}
|
|
</style>
|
|
}
|
|
|
|
<div class="container-fluid py-3">
|
|
<div class="d-flex align-items-center justify-content-between mb-3">
|
|
<div>
|
|
<h4 class="mb-0"><i class="bi bi-heart-pulse me-2 text-primary"></i>Company Health</h4>
|
|
<small class="text-muted">Churn risk signals across all tenants</small>
|
|
</div>
|
|
</div>
|
|
|
|
@* Churned account visibility banner *@
|
|
@if (churnedCount > 0 && !showChurned)
|
|
{
|
|
<div class="alert alert-secondary alert-permanent d-flex align-items-center gap-2 mb-3 py-2">
|
|
<i class="bi bi-eye-slash text-muted"></i>
|
|
<span class="small"><strong>@churnedCount</strong> churned @(churnedCount == 1 ? "account" : "accounts") (expired or canceled 14+ days ago) hidden from scores and totals.</span>
|
|
<a href="@Url.Action("Index", new { risk = ViewBag.Risk, search = ViewBag.Search, configIssuesOnly = ViewBag.ConfigIssuesOnly, showChurned = true })"
|
|
class="btn btn-sm btn-outline-secondary ms-auto py-0">Show churned</a>
|
|
</div>
|
|
}
|
|
else if (showChurned && churnedCount > 0)
|
|
{
|
|
<div class="alert alert-warning alert-permanent d-flex align-items-center gap-2 mb-3 py-2">
|
|
<i class="bi bi-eye text-warning"></i>
|
|
<span class="small">Showing all accounts including <strong>@churnedCount</strong> churned.</span>
|
|
<a href="@Url.Action("Index", new { risk = ViewBag.Risk, search = ViewBag.Search, configIssuesOnly = ViewBag.ConfigIssuesOnly, showChurned = false })"
|
|
class="btn btn-sm btn-outline-secondary ms-auto py-0">Hide churned</a>
|
|
</div>
|
|
}
|
|
|
|
@* Summary stat cards *@
|
|
<div class="row g-3 mb-3">
|
|
<div class="col-6 col-lg-3">
|
|
<a href="@Url.Action("Index", new { risk = "", search = ViewBag.Search })"
|
|
class="card border-0 shadow-sm text-decoration-none @(string.IsNullOrEmpty(ViewBag.Risk as string) ? "border-bottom border-3 border-secondary" : "")">
|
|
<div class="card-body py-3">
|
|
<div class="d-flex align-items-center gap-3">
|
|
<div class="rounded-circle bg-secondary bg-opacity-10 p-2">
|
|
<i class="bi bi-building text-secondary fs-5"></i>
|
|
</div>
|
|
<div>
|
|
<div class="fs-4 fw-bold">@Model.Count</div>
|
|
<div class="small text-muted">All Companies</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</a>
|
|
</div>
|
|
<div class="col-6 col-lg-3">
|
|
<a href="@Url.Action("Index", new { risk = "Healthy", search = ViewBag.Search })"
|
|
class="card border-0 shadow-sm text-decoration-none @(ViewBag.Risk == "Healthy" ? "border-bottom border-3 border-success" : "")">
|
|
<div class="card-body py-3">
|
|
<div class="d-flex align-items-center gap-3">
|
|
<div class="rounded-circle bg-success bg-opacity-10 p-2">
|
|
<i class="bi bi-check-circle text-success fs-5"></i>
|
|
</div>
|
|
<div>
|
|
<div class="fs-4 fw-bold text-success">@ViewBag.HealthyCount</div>
|
|
<div class="small text-muted">Healthy</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</a>
|
|
</div>
|
|
<div class="col-6 col-lg-3">
|
|
<a href="@Url.Action("Index", new { risk = "AtRisk", search = ViewBag.Search })"
|
|
class="card border-0 shadow-sm text-decoration-none @(ViewBag.Risk == "AtRisk" ? "border-bottom border-3 border-warning" : "")">
|
|
<div class="card-body py-3">
|
|
<div class="d-flex align-items-center gap-3">
|
|
<div class="rounded-circle bg-warning bg-opacity-10 p-2">
|
|
<i class="bi bi-exclamation-triangle text-warning fs-5"></i>
|
|
</div>
|
|
<div>
|
|
<div class="fs-4 fw-bold text-warning">@ViewBag.AtRiskCount</div>
|
|
<div class="small text-muted">At Risk</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</a>
|
|
</div>
|
|
<div class="col-6 col-lg-3">
|
|
<a href="@Url.Action("Index", new { risk = "Critical", search = ViewBag.Search })"
|
|
class="card border-0 shadow-sm text-decoration-none @(ViewBag.Risk == "Critical" ? "border-bottom border-3 border-danger" : "")">
|
|
<div class="card-body py-3">
|
|
<div class="d-flex align-items-center gap-3">
|
|
<div class="rounded-circle bg-danger bg-opacity-10 p-2">
|
|
<i class="bi bi-x-circle text-danger fs-5"></i>
|
|
</div>
|
|
<div>
|
|
<div class="fs-4 fw-bold text-danger">@(ViewBag.CriticalCount + ViewBag.NeverActivatedCount)</div>
|
|
<div class="small text-muted">Critical / Dormant</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
@* Config issues summary card *@
|
|
<div class="row g-3 mb-3">
|
|
<div class="col-12 col-lg-4">
|
|
<a href="@Url.Action("Index", new { configIssuesOnly = true, search = ViewBag.Search })"
|
|
class="card border-0 shadow-sm text-decoration-none @((bool)(ViewBag.ConfigIssuesOnly ?? false) ? "border-bottom border-3 border-danger" : "")">
|
|
<div class="card-body py-3">
|
|
<div class="d-flex align-items-center gap-3">
|
|
<div class="rounded-circle bg-danger bg-opacity-10 p-2">
|
|
<i class="bi bi-tools text-danger fs-5"></i>
|
|
</div>
|
|
<div>
|
|
<div class="fs-4 fw-bold @((int)(ViewBag.ConfigIssuesCount ?? 0) > 0 ? "text-danger" : "text-success")">
|
|
@ViewBag.ConfigIssuesCount
|
|
</div>
|
|
<div class="small text-muted">Config Issues</div>
|
|
</div>
|
|
@if ((int)(ViewBag.ConfigIssuesCount ?? 0) > 0)
|
|
{
|
|
<div class="ms-auto">
|
|
<span class="badge bg-danger rounded-pill">Action needed</span>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
@* Search + filter bar *@
|
|
<div class="card border-0 shadow-sm mb-3">
|
|
<div class="card-body py-2">
|
|
<form method="get" class="row g-2 align-items-end">
|
|
<div class="col-md-4">
|
|
<input name="search" value="@ViewBag.Search" class="form-control form-control-sm"
|
|
placeholder="Company name or email…" />
|
|
</div>
|
|
<div class="col-md-3">
|
|
<select name="risk" class="form-select form-select-sm">
|
|
<option value="">All risk levels</option>
|
|
<option value="Healthy" selected="@(ViewBag.Risk == "Healthy")">Healthy</option>
|
|
<option value="AtRisk" selected="@(ViewBag.Risk == "AtRisk")">At Risk</option>
|
|
<option value="Critical" selected="@(ViewBag.Risk == "Critical")">Critical</option>
|
|
<option value="NeverActivated" selected="@(ViewBag.Risk == "NeverActivated")">Never Activated</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-3 d-flex align-items-center">
|
|
<div class="form-check mb-0">
|
|
<input class="form-check-input" type="checkbox" name="configIssuesOnly" value="true"
|
|
id="configOnly" @((bool)(ViewBag.ConfigIssuesOnly ?? false) ? "checked" : "") />
|
|
<label class="form-check-label small" for="configOnly">Config issues only</label>
|
|
</div>
|
|
</div>
|
|
<input type="hidden" name="showChurned" value="@showChurned.ToString().ToLower()" />
|
|
<div class="col-auto">
|
|
<button class="btn btn-sm btn-primary">Filter</button>
|
|
<a asp-action="Index" class="btn btn-sm btn-outline-secondary ms-1">Clear</a>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
@* Table *@
|
|
<div class="card border-0 shadow-sm">
|
|
<div class="table-responsive">
|
|
<table class="table table-hover align-middle mb-0 small">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th>Company</th>
|
|
<th>Plan</th>
|
|
<th style="width:110px">Risk</th>
|
|
<th style="width:70px">Score</th>
|
|
<th>Last Login</th>
|
|
<th>Jobs 30d</th>
|
|
<th>Jobs 90d</th>
|
|
<th>Total</th>
|
|
<th>Engagement Signals</th>
|
|
<th>Config</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@if (!Model.Any())
|
|
{
|
|
<tr><td colspan="10" class="text-center text-muted py-5">No companies match the current filter.</td></tr>
|
|
}
|
|
@foreach (var h in Model)
|
|
{
|
|
var manageUrl = Url.Action("Manage", "SubscriptionManagement", new { id = h.Id });
|
|
<tr class="@RowClass(h.RiskLevel)" style="cursor:pointer" onclick="window.location='@manageUrl'">
|
|
<td>
|
|
<div class="fw-medium">
|
|
@h.CompanyName
|
|
@if (h.IsComped)
|
|
{
|
|
<span class="badge bg-success-subtle text-success ms-1 small">Comped</span>
|
|
}
|
|
@if (!h.IsActive)
|
|
{
|
|
<span class="badge bg-danger ms-1 small">Inactive</span>
|
|
}
|
|
</div>
|
|
<small class="text-muted">@h.PrimaryContactEmail</small>
|
|
</td>
|
|
<td class="text-muted">@h.PlanDisplayName</td>
|
|
<td>
|
|
<span class="badge @RiskBadge(h.RiskLevel)">@RiskLabel(h.RiskLevel)</span>
|
|
</td>
|
|
<td>
|
|
@if (h.RiskLevel == ChurnRisk.NeverActivated)
|
|
{
|
|
<span class="text-muted">—</span>
|
|
}
|
|
else
|
|
{
|
|
<span class="fw-semibold @ScoreColor(h.HealthScore)">@h.HealthScore</span>
|
|
}
|
|
</td>
|
|
<td class="@LoginClass(h.DaysSinceLastLogin)">
|
|
@LoginLabel(h.DaysSinceLastLogin)
|
|
</td>
|
|
<td>
|
|
@if (h.JobsLast30Days > 0)
|
|
{ <span class="fw-medium text-success">@h.JobsLast30Days</span> }
|
|
else
|
|
{ <span class="text-muted">0</span> }
|
|
</td>
|
|
<td>
|
|
@if (h.JobsLast90Days > 0)
|
|
{ <span class="fw-medium">@h.JobsLast90Days</span> }
|
|
else
|
|
{ <span class="text-muted">0</span> }
|
|
</td>
|
|
<td class="text-muted">@h.TotalJobs</td>
|
|
<td>
|
|
@if (h.RiskSignals.Any())
|
|
{
|
|
<div class="d-flex flex-wrap gap-1">
|
|
@foreach (var s in h.RiskSignals)
|
|
{
|
|
<span class="badge bg-secondary-subtle text-secondary fw-normal">@s</span>
|
|
}
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<span class="text-success small"><i class="bi bi-check-circle me-1"></i>All clear</span>
|
|
}
|
|
</td>
|
|
<td onclick="event.stopPropagation()">
|
|
@if (h.ConfigHealth.IsHealthy)
|
|
{
|
|
<span class="badge bg-success"><i class="bi bi-check-lg me-1"></i>OK</span>
|
|
}
|
|
else
|
|
{
|
|
var configUrl = Url.Action("Details", "Companies", new { id = h.Id });
|
|
<a href="@configUrl" class="badge @ConfigBadgeClass(h.ConfigHealth) text-decoration-none"
|
|
title="@string.Join(" | ", h.ConfigHealth.Issues.Select(i => i.Title))">
|
|
<i class="bi bi-exclamation-triangle me-1"></i>
|
|
@h.ConfigHealth.Issues.Count issue@(h.ConfigHealth.Issues.Count == 1 ? "" : "s")
|
|
</a>
|
|
}
|
|
</td>
|
|
<td onclick="event.stopPropagation()">
|
|
<a href="@manageUrl" class="btn btn-sm btn-outline-secondary py-0 px-2">
|
|
<i class="bi bi-pencil"></i>
|
|
</a>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Mobile card view — shown on screens < 992px -->
|
|
<div class="mobile-card-view">
|
|
<div class="mobile-card-list">
|
|
@foreach (var h in Model)
|
|
{
|
|
var manageUrl = Url.Action("Manage", "SubscriptionManagement", new { id = h.Id });
|
|
<a href="@manageUrl" class="mobile-data-card text-decoration-none">
|
|
<div class="mobile-card-header">
|
|
<div class="mobile-card-icon bg-primary"><i class="bi bi-building"></i></div>
|
|
<div class="mobile-card-title">
|
|
<h6>
|
|
@h.CompanyName
|
|
@if (!h.IsActive) { <span class="badge bg-danger ms-1" style="font-size:0.6rem">Inactive</span> }
|
|
</h6>
|
|
<small>Last login: @LoginLabel(h.DaysSinceLastLogin)</small>
|
|
</div>
|
|
</div>
|
|
<div class="mobile-card-body">
|
|
<div class="mobile-card-row">
|
|
<span class="mobile-card-label">Risk</span>
|
|
<span class="mobile-card-value"><span class="badge @RiskBadge(h.RiskLevel)">@RiskLabel(h.RiskLevel)</span></span>
|
|
</div>
|
|
@if (h.RiskLevel != ChurnRisk.NeverActivated)
|
|
{
|
|
<div class="mobile-card-row">
|
|
<span class="mobile-card-label">Score</span>
|
|
<span class="mobile-card-value fw-semibold @ScoreColor(h.HealthScore)">@h.HealthScore</span>
|
|
</div>
|
|
}
|
|
<div class="mobile-card-row">
|
|
<span class="mobile-card-label">Plan</span>
|
|
<span class="mobile-card-value">@h.PlanDisplayName</span>
|
|
</div>
|
|
<div class="mobile-card-row">
|
|
<span class="mobile-card-label">Jobs (30d / 90d)</span>
|
|
<span class="mobile-card-value">@h.JobsLast30Days / @h.JobsLast90Days</span>
|
|
</div>
|
|
@if (h.RiskSignals.Any())
|
|
{
|
|
<div class="mobile-card-row">
|
|
<span class="mobile-card-label">Signals</span>
|
|
<span class="mobile-card-value">@string.Join(", ", h.RiskSignals)</span>
|
|
</div>
|
|
}
|
|
</div>
|
|
<div class="mobile-card-footer">
|
|
<span class="btn btn-sm btn-outline-secondary">Manage →</span>
|
|
</div>
|
|
</a>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
@if (!Model.Any(h => h.RiskLevel != ChurnRisk.Healthy) && Model.Any())
|
|
{
|
|
<div class="card-footer text-center py-3 text-success">
|
|
<i class="bi bi-check-circle-fill me-2"></i>All tenants are healthy — no churn signals detected.
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|