Replace literal Unicode special chars with HTML entities across all 233 views
Sweeps em dashes, en dashes, multiplication signs, ellipses, and curly quotes to their HTML entity equivalents (— – × … ‘ ’) in all .cshtml files, skipping <script> blocks. Prevents encoding corruption from AI tools and Windows encoding mismatches that caused recurring symbol bugs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,401 +0,0 @@
|
||||
@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>
|
||||
|
||||
Reference in New Issue
Block a user