Files
PowderCoatingLogix/src/PowderCoating.Web/Views/Revenue/Index.cshtml
T
spouliot 64a9c1531b Fix — HTML entity rendering across 60 views
Razor's @() expression auto-encodes &, turning — into — which
rendered as literal text in the browser. Wrapped all such expressions in
@Html.Raw() so the em-dash entity is passed through unescaped.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 09:27:45 -04:00

314 lines
14 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@using PowderCoating.Core.Enums
@using PowderCoating.Web.Controllers
@model RevenueDashboardViewModel
@section Styles {
<style>
[data-bs-theme="dark"] .card-header.bg-white { background-color: var(--bs-card-cap-bg) !important; }
</style>
}
@{
ViewData["Title"] = "Revenue Dashboard";
}
<div class="container-fluid py-3">
<div class="d-flex align-items-center justify-content-between mb-3">
<h4 class="mb-0">
<i class="bi bi-graph-up-arrow me-2 text-primary"></i>Revenue Dashboard
</h4>
<small class="text-muted">Based on active paying companies &amp; plan pricing</small>
</div>
@* ── Top KPI cards ── *@
<div class="row g-3 mb-3">
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm h-100 border-start border-4 border-primary">
<div class="card-body">
<div class="small text-muted mb-1">Monthly Recurring Revenue</div>
<div class="fs-3 fw-bold text-primary">@Model.Mrr.ToString("C0")</div>
<div class="small text-muted">MRR</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm h-100 border-start border-4 border-success">
<div class="card-body">
<div class="small text-muted mb-1">Annual Recurring Revenue</div>
<div class="fs-3 fw-bold text-success">@Model.Arr.ToString("C0")</div>
<div class="small text-muted">ARR (MRR × 12)</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm h-100 border-start border-4 border-info">
<div class="card-body">
<div class="small text-muted mb-1">Avg Revenue / Company</div>
<div class="fs-3 fw-bold text-info">@Model.AvgRevenuePerCompany.ToString("C0")</div>
<div class="small text-muted">ARPC per month</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm h-100 border-start border-4" style="border-color:#6f42c1!important">
<div class="card-body">
<div class="small text-muted mb-1">Stripe-Managed</div>
<div class="fs-3 fw-bold" style="color:#6f42c1">@Model.StripeManaged</div>
<div class="small text-muted">companies on Stripe billing</div>
</div>
</div>
</div>
</div>
<div class="row g-3 mb-3">
@* ── Company Status Breakdown ── *@
<div class="col-md-4">
<div class="card shadow-sm h-100">
<div class="card-header fw-semibold py-2">Company Status</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<tbody>
<tr>
<td><span class="badge bg-success">Active</span></td>
<td class="fw-bold">@Model.ActiveCount</td>
</tr>
<tr>
<td><span class="badge bg-warning text-dark">Grace Period</span></td>
<td class="fw-bold">@Model.GracePeriodCount</td>
</tr>
<tr>
<td><span class="badge bg-danger">Expired</span></td>
<td class="fw-bold">@Model.ExpiredCount</td>
</tr>
<tr>
<td><span class="badge bg-secondary">Canceled / Inactive</span></td>
<td class="fw-bold">@Model.CanceledCount</td>
</tr>
<tr>
<td><span class="badge bg-info text-dark">Comped</span></td>
<td class="fw-bold">@Model.CompedCount</td>
</tr>
<tr class="table-light">
<td class="fw-semibold">Total</td>
<td class="fw-bold">@Model.TotalCompanies</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
@* ── Month-over-Month ── *@
<div class="col-md-4">
<div class="card shadow-sm h-100">
<div class="card-header fw-semibold py-2">Month-over-Month</div>
<div class="card-body">
<div class="row text-center g-2">
<div class="col-4">
<div class="fs-4 fw-bold text-success">+@Model.NewThisMonth</div>
<div class="small text-muted">New this month</div>
</div>
<div class="col-4">
<div class="fs-4 fw-bold text-secondary">@Model.NewLastMonth</div>
<div class="small text-muted">New last month</div>
</div>
<div class="col-4">
<div class="fs-4 fw-bold text-danger">@Model.ChurnThisMonth</div>
<div class="small text-muted">Churn this month</div>
</div>
</div>
<hr class="my-2">
<div class="text-center">
<div class="fs-5 fw-bold @(Model.NewThisMonth - Model.ChurnThisMonth >= 0 ? "text-success" : "text-danger")">
@(Model.NewThisMonth - Model.ChurnThisMonth >= 0 ? "+" : "")@(Model.NewThisMonth - Model.ChurnThisMonth) net
</div>
<div class="small text-muted">Net company growth this month</div>
</div>
</div>
</div>
</div>
@* ── Plan Distribution ── *@
<div class="col-md-4">
<div class="card shadow-sm h-100">
<div class="card-header fw-semibold py-2">Revenue by Plan</div>
<div class="card-body p-0">
@if (!Model.PlanDistribution.Any())
{
<div class="p-3 text-muted small">No paying companies on record.</div>
}
else
{
<table class="table table-sm mb-0">
<thead class="table-light">
<tr>
<th>Plan</th>
<th class="text-center">Co.</th>
<th class="text-end">MRR</th>
</tr>
</thead>
<tbody>
@foreach (var row in Model.PlanDistribution)
{
<tr>
<td class="small">@row.PlanName</td>
<td class="text-center small">@row.CompanyCount</td>
<td class="text-end small fw-semibold">@row.Revenue.ToString("C0")</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
</div>
</div>
@* ── MRR Trend Chart ── *@
<div class="card shadow-sm mb-3">
<div class="card-header fw-semibold py-2">
Estimated MRR Trend &mdash; Last 12 Months
<small class="text-muted fw-normal ms-2">(approximation based on current plan prices × active companies)</small>
</div>
<div class="card-body">
<canvas id="mrrChart" height="80"></canvas>
</div>
</div>
@* ── Subscription Alerts ── *@
@if (Model.SubscriptionAlerts.Any())
{
<div class="card shadow-sm">
<div class="card-header fw-semibold py-2 text-warning">
<i class="bi bi-exclamation-triangle me-1"></i>Subscriptions Needing Attention
</div>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th>Company</th>
<th>Plan</th>
<th>Status</th>
<th>End Date</th>
<th>Days</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var alert in Model.SubscriptionAlerts)
{
var statusClass = alert.Status switch
{
SubscriptionStatus.GracePeriod => "warning text-dark",
SubscriptionStatus.Expired => "danger",
_ => "info text-dark"
};
var daysText = alert.DaysUntilExpiry < 0
? $"{Math.Abs(alert.DaysUntilExpiry)}d overdue"
: $"in {alert.DaysUntilExpiry}d";
<tr>
<td class="small fw-semibold">@alert.CompanyName</td>
<td class="small">@alert.PlanName</td>
<td><span class="badge bg-@statusClass">@alert.Status</span></td>
<td class="small">@Html.Raw(alert.EndDate?.ToString("MM/dd/yyyy") ?? "&mdash;")</td>
<td class="small @(alert.DaysUntilExpiry < 0 ? "text-danger" : "text-warning")">@daysText</td>
<td>
<a asp-controller="SubscriptionManagement" asp-action="Manage"
asp-route-id="@alert.Id" class="btn btn-outline-secondary btn-sm py-0">Manage</a>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Mobile card view &mdash; shown on screens < 992px (table-responsive hidden by mobile-cards.css) -->
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var alert in Model.SubscriptionAlerts)
{
var alertStatusClass = alert.Status switch
{
SubscriptionStatus.GracePeriod => "warning text-dark",
SubscriptionStatus.Expired => "danger",
_ => "info text-dark"
};
var alertDaysText = alert.DaysUntilExpiry < 0
? $"{Math.Abs(alert.DaysUntilExpiry)}d overdue"
: $"in {alert.DaysUntilExpiry}d";
<a href="@Url.Action("Manage", "SubscriptionManagement", new { id = alert.Id })" class="mobile-data-card text-decoration-none">
<div class="mobile-card-header">
<div class="mobile-card-icon bg-warning"><i class="bi bi-exclamation-triangle"></i></div>
<div class="mobile-card-title">
<h6>@alert.CompanyName</h6>
<small class="text-muted">@alert.PlanName</small>
</div>
<span class="badge bg-@alertStatusClass">@alert.Status</span>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">End Date</span>
<span class="mobile-card-value">@Html.Raw(alert.EndDate?.ToString("MM/dd/yyyy") ?? "&mdash;")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Days</span>
<span class="mobile-card-value @(alert.DaysUntilExpiry < 0 ? "text-danger" : "text-warning")">@alertDaysText</span>
</div>
</div>
<div class="mobile-card-footer">
<span class="btn btn-sm btn-outline-secondary">Manage →</span>
</div>
</a>
}
</div>
</div>
</div>
}
</div>
@section Scripts {
<script src="~/lib/chartjs/chart.umd.min.js"></script>
<script>
(function () {
const labels = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.TrendMonths.Select(t => t.Month)));
const mrrData = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.TrendMonths.Select(t => t.Mrr)));
new Chart(document.getElementById('mrrChart'), {
type: 'line',
data: {
labels,
datasets: [{
label: 'MRR ($)',
data: mrrData,
borderColor: '#0d6efd',
backgroundColor: 'rgba(13,110,253,0.1)',
fill: true,
tension: 0.3,
pointRadius: 4,
pointHoverRadius: 6
}]
},
options: {
responsive: true,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: ctx => '$' + ctx.parsed.y.toLocaleString('en-US', { minimumFractionDigits: 0 })
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
callback: v => '$' + v.toLocaleString()
}
}
}
}
});
})();
</script>
}