Files
PowderCoatingLogix/src/PowderCoating.Web/Views/CompanyHealth/Index.cshtml
T
spouliot a0bdd2b5b4 Sweep all .cshtml files for encoding corruption; add pre-commit guard
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>
2026-05-20 21:37:10 -04:00

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&hellip;" />
</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">&mdash;</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 &mdash; 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 &mdash; no churn signals detected.
</div>
}
</div>
</div>