@using PowderCoating.Web.Controllers @using PowderCoating.Web.ViewModels.Reports @model AnalyticsDashboardViewModel @{ ViewData["Title"] = "Analytics & Reports"; ViewData["PageIcon"] = "bi-graph-up"; var allowAccounting = Context.Items["AllowAccounting"] as bool? ?? false; }

Total Revenue

@Model.TotalRevenue.ToString("C0")

@if (Model.MonthOverMonthGrowth != 0) { @Math.Abs(Model.MonthOverMonthGrowth)% MoM }

Active Jobs

@Model.ActiveJobsCount

@Model.CompletedJobsThisMonth completed this month

Quote Win Rate

@Model.QuoteWinRate%

of @Model.TotalQuotes quotes

Active Customers

@Model.ActiveCustomersCount

@Model.CustomerRetentionRate% retention

Avg Job Value

@Model.AverageJobValue.ToString("C0")

Appointments This Month

@Model.AppointmentsThisMonth

@Model.AppointmentCompletionRate% completion

Avg Job Duration

@Model.AverageJobDuration.ToString("F1")

days

Low Stock Items

@Model.LowStockItems.Count

need reordering
Revenue Trend
Job Status
@if (Model.JobsByStatus.Any()) { } else {

No data

}
Top Customers
@if (Model.TopCustomers.Any()) { var maxRev = Model.TopCustomers.Max(c => c.Revenue); @foreach (var customer in Model.TopCustomers.Take(5)) { var pct = maxRev > 0 ? (double)customer.Revenue / (double)maxRev * 100 : 0;
@customer.Name @customer.Revenue.ToString("C0")
} } else {

No customer data yet

}
Equipment Health
@if (Model.EquipmentByStatus.Any()) { var totalEquip = Model.EquipmentByStatus.Values.Sum(); @foreach (var (status, count) in Model.EquipmentByStatus) { var pct = totalEquip > 0 ? (double)count / totalEquip * 100 : 0; var barClass = status switch { "Operational" => "bg-success", "Needs Maintenance" => "bg-warning", "Under Maintenance" => "bg-info", "Out Of Service" => "bg-danger", _ => "bg-secondary" };
@status @count
} } else {

No equipment data yet

}
Revenue & Job Count Trend
Average Order Value Trend
Revenue by Customer Type
@if (Model.RevenueByCustomerType.Any()) { } else {

No data

}
Revenue by Job Priority
@if (Model.RevenueByPriority.Any()) { } else {

No data

}
Top 10 Customers by Revenue
@if (Model.TopCustomers.Any()) {
@foreach (var customer in Model.TopCustomers) { }
Customer Jobs Revenue
@customer.Name @customer.JobCount @customer.Revenue.ToString("C0")
} else {

No customer revenue data yet

}
Jobs by Priority
@if (Model.ActiveJobsByPriority.Any()) { } else {

No active jobs

}
Appointments by Type
@if (Model.AppointmentsByType.Any()) { } else {

No appointments

}
Appointments by Day
@if (Model.AppointmentsByDayOfWeek.Any()) { } else {

No data

}
Worker Performance
@if (Model.WorkerStats.Any()) {
@foreach (var worker in Model.WorkerStats) { }
Worker Role Jobs Assigned Jobs Completed Appointments Completion Rate
@worker.Name @worker.Role @worker.JobsAssigned @worker.JobsCompleted @worker.AppointmentsAssigned
@worker.CompletionRate%
@foreach (var worker in Model.WorkerStats) {
@worker.Name
@worker.Role
Jobs Assigned @worker.JobsAssigned
Jobs Completed @worker.JobsCompleted
Appointments @worker.AppointmentsAssigned
Completion Rate
@worker.CompletionRate%
}
} else {

No worker data yet

}
Low Stock Alert
@if (Model.LowStockItems.Any()) { @Model.LowStockItems.Count items }
@if (Model.LowStockItems.Any()) {
@foreach (var item in Model.LowStockItems) { }
Item On Hand Reorder Point Status
@item.Name @if (!string.IsNullOrEmpty(item.ColorName)) {
@item.ColorName }
@item.QuantityOnHand.ToString("F1") @item.UnitOfMeasure @item.ReorderPoint.ToString("F1") @item.UnitOfMeasure @(item.QuantityOnHand == 0 ? "Out of Stock" : "Low Stock")
} else {

All items sufficiently stocked

}
New Customers
Quote Conversion Funnel
@if (Model.QuoteFunnel.Total > 0) { var stages = new[] { ("Draft", Model.QuoteFunnel.Draft, "secondary"), ("Sent", Model.QuoteFunnel.Sent, "primary"), ("Approved", Model.QuoteFunnel.Approved, "success"), ("Converted", Model.QuoteFunnel.Converted, "info") }; var maxCount = Math.Max(Model.QuoteFunnel.Draft, Math.Max(Model.QuoteFunnel.Sent, Math.Max(Model.QuoteFunnel.Approved, Model.QuoteFunnel.Converted)));

@Model.QuoteFunnel.ConversionRate%

Overall Conversion Rate
Rejected: @Model.QuoteFunnel.Rejected    Expired: @Model.QuoteFunnel.Expired
@foreach (var (stage, count, color) in stages) { var pct = maxCount > 0 ? (double)count / maxCount * 100 : 0; var dropoffPct = stage == "Sent" && Model.QuoteFunnel.Draft > 0 ? Math.Round((1 - (double)count / Model.QuoteFunnel.Draft) * 100, 1) : stage == "Approved" && Model.QuoteFunnel.Sent > 0 ? Math.Round((1 - (double)count / Model.QuoteFunnel.Sent) * 100, 1) : stage == "Converted" && Model.QuoteFunnel.Approved > 0 ? Math.Round((1 - (double)count / Model.QuoteFunnel.Approved) * 100, 1) : 0;
@stage @if (dropoffPct > 0) { @dropoffPct% drop }
@count
@if (pct > 15) { @count quotes }
} } else {

No quote data yet

}
Top 10 Customers by Lifetime Value
@if (Model.CustomerLifetimeValue.Any()) {
@foreach (var customer in Model.CustomerLifetimeValue) { }
Customer Total Jobs Avg Order Value Lifetime Revenue First Job Last Job
@customer.CustomerName @customer.JobCount @customer.AvgOrderValue.ToString("C0") @customer.TotalRevenue.ToString("C0") @customer.FirstJobDate.ToString("MMM yyyy") @customer.LastJobDate.ToString("MMM yyyy")
@foreach (var customer in Model.CustomerLifetimeValue) {
@customer.CustomerName
@customer.JobCount Jobs
Lifetime Revenue @customer.TotalRevenue.ToString("C0")
Avg Order Value @customer.AvgOrderValue.ToString("C0")
First Job @customer.FirstJobDate.ToString("MMM yyyy")
Last Job @customer.LastJobDate.ToString("MMM yyyy")
}
} else {

No customer lifetime value data yet

}
@if (allowAccounting) {
AI Financial Summary
Click "Generate" to get a plain-English summary of your financial health.

Total Invoiced

@Model.TotalInvoiced.ToString("C0")

Collected

@Model.TotalCollected.ToString("C0")

Outstanding

@Model.TotalOutstanding.ToString("C0")

@Model.InvoicesDraftCount draft

Overdue

@Model.TotalOverdue.ToString("C0")

@Model.InvoicesOverdueCount invoice@(Model.InvoicesOverdueCount != 1 ? "s" : "")
Monthly: Invoiced vs Collected
Invoiced Collected
Payment Performance
@Model.AvgDaysToPayment.ToString("F0")

Avg. Days to Payment


Paid Invoices @Model.InvoicesPaidCount
Overdue Invoices @Model.InvoicesOverdueCount
Draft Invoices @Model.InvoicesDraftCount
AR Aging
@if (Model.AgingBuckets.Any(b => b.Count > 0)) { @{ var agingRowColors = new[] { "", "table-warning", "table-orange", "table-danger" }; } @for (int bi = 0; bi < Model.AgingBuckets.Count; bi++) { var bucket = Model.AgingBuckets[bi]; var rowClass = bi == 0 ? "" : bi == 1 ? "table-warning" : bi == 2 ? "text-warning" : "table-danger"; }
Age Invoices Amount
@bucket.Label @bucket.Count @bucket.Amount.ToString("C0")
Total Outstanding @Model.AgingBuckets.Sum(b => b.Count) @Model.AgingBuckets.Sum(b => b.Amount).ToString("C0")
} else {

No outstanding invoices

}
Top Outstanding Customers
@if (Model.TopOutstandingCustomers.Any()) { @foreach (var oc in Model.TopOutstandingCustomers) { }
Customer Invoices Balance
@oc.CustomerName @oc.OpenInvoiceCount @oc.OutstandingBalance.ToString("C0")
} else {

No outstanding customer balances

}
Recent Payments
View All Invoices
@if (Model.RecentPayments.Any()) {
@foreach (var pmt in Model.RecentPayments) { }
Date Invoice # Customer Method Amount
@pmt.PaymentDate.ToString("MM/dd/yyyy") @pmt.InvoiceNumber @pmt.CustomerName @pmt.PaymentMethod @pmt.Amount.ToString("C")
} else {

No payments recorded yet

}

Total Bills (All Time)

@Model.TotalBilled.ToString("C0")

Total Bills Paid

@Model.TotalBillsPaid.ToString("C0")

AP Outstanding

@Model.TotalApOutstanding.ToString("C0")

Direct Expenses (All Time)

@Model.TotalDirectExpenses.ToString("C0")

Profit & Loss — Revenue vs Expenses
Expenses by Account
@if (Model.ExpensesByAccount.Any()) { } else {

No expense data yet.

}
AP Aging
View Bills
@foreach (var bucket in Model.ApAgingBuckets) { }
Bucket Count Balance
@bucket.Label @bucket.Count @bucket.Amount.ToString("C")
Total @Model.ApAgingBuckets.Sum(b => b.Count) @Model.ApAgingBuckets.Sum(b => b.Amount).ToString("C")
Top Vendor Spend
View Vendors
@if (Model.VendorSpend.Any()) { @foreach (var v in Model.VendorSpend) { }
Vendor Bills Billed Paid Balance
@v.VendorName @v.BillCount @v.TotalBilled.ToString("C") @v.TotalPaid.ToString("C") @v.BalanceDue.ToString("C")
} else {

No vendor bills recorded yet.

}
@if (Model.ExpensesByAccount.Any()) {
Expense Breakdown by Account
View All Expenses
@foreach (var acct in Model.ExpensesByAccount) { }
Account Transactions Total
@acct.AccountName @acct.Count @acct.Amount.ToString("C")
Total @Model.ExpensesByAccount.Sum(e => e.Count) @Model.ExpensesByAccount.Sum(e => e.Amount).ToString("C")
}
}
Total Powder Used
@Model.TotalPowderUsedLbs.ToString("N1") lbs
last @Model.SelectedMonths months
Powder Cost
@Model.TotalPowderCost.ToString("C0")
last @Model.SelectedMonths months
Jobs Tracked
@Model.TotalJobsWithPowderUsage
with powder usage
Colors Used
@Model.ColorsUsedCount
distinct powders
Top Colors by Usage (lbs)
@if (Model.TopColorsByUsage.Any()) { } else {

No powder usage recorded yet

Usage is tracked when jobs are completed
}
Monthly Consumption Trend
@if (Model.TopColorsByUsage.Any()) {
Color Usage Detail
Last @Model.SelectedMonths months
@{ var totalLbs = Model.TopColorsByUsage.Sum(c => c.TotalLbsUsed); } @foreach (var color in Model.TopColorsByUsage) { var pct = totalLbs > 0 ? color.TotalLbsUsed / totalLbs * 100 : 0; var avgPerJob = color.JobCount > 0 ? color.TotalLbsUsed / color.JobCount : 0; }
Color / SKU Manufacturer Lbs Used Est. Cost Jobs Avg / Job
@color.DisplayLabel
@color.SKU
@(color.Manufacturer ?? "—")
@color.TotalLbsUsed.ToString("N1") lbs
@color.TotalCost.ToString("C") @color.JobCount @avgPerJob.ToString("N1") lbs
Total @Model.TotalPowderUsedLbs.ToString("N1") lbs @Model.TotalPowderCost.ToString("C") @Model.TotalJobsWithPowderUsage
}

Total Invoiced

@Model.SalesByCustomerTotalInvoiced.ToString("C0")

Across all customers

Total Collected

@Model.SalesByCustomerTotalPaid.ToString("C0")

@((Model.SalesByCustomerTotalInvoiced > 0 ? Math.Round(Model.SalesByCustomerTotalPaid / Model.SalesByCustomerTotalInvoiced * 100, 1) : 0).ToString("N1"))% collection rate

Active Customers

@Model.SalesByCustomer.Count

Avg @((Model.SalesByCustomer.Count > 0 ? Model.SalesByCustomerTotalInvoiced / Model.SalesByCustomer.Count : 0).ToString("C0")) per customer
@if (Model.SalesByCustomer.Any()) {
Top 10 Customers by Revenue
All Customers
@Model.SalesByCustomer.Count customer@(Model.SalesByCustomer.Count == 1 ? "" : "s")
@foreach (var c in Model.SalesByCustomer) { var share = Model.SalesByCustomerTotalInvoiced > 0 ? (double)(c.TotalInvoiced / Model.SalesByCustomerTotalInvoiced * 100) : 0; }
Customer Type Invoices Total Invoiced Total Paid Balance Due Avg Invoice Last Invoice Share
@c.CustomerName @if (c.IsCommercial) { Commercial } else { Personal } @c.InvoiceCount @c.TotalInvoiced.ToString("C") @c.TotalPaid.ToString("C") @c.BalanceDue.ToString("C") @c.AvgInvoiceValue.ToString("C") @(c.LastInvoiceDate?.ToString("MMM d, yyyy") ?? "—")
@share.ToString("F1")%
Total @Model.SalesByCustomerTotalInvoiced.ToString("C") @Model.SalesByCustomerTotalPaid.ToString("C") @((Model.SalesByCustomerTotalInvoiced - Model.SalesByCustomerTotalPaid).ToString("C"))
} else {

No invoice data available for the selected period.

}
@{ var retActive = Model.CustomerRetention.Count(r => r.RetentionStatus == "Active"); var retAtRisk = Model.CustomerRetention.Count(r => r.RetentionStatus == "At Risk"); var retLapsing = Model.CustomerRetention.Count(r => r.RetentionStatus == "Lapsing"); var retChurned = Model.CustomerRetention.Count(r => r.RetentionStatus == "Churned"); var retNever = Model.CustomerRetention.Count(r => r.RetentionStatus == "Never Ordered"); }
@retActive
Active
<30 days
@retAtRisk
At Risk
30–60 days
@retLapsing
Lapsing
60–90 days
@retChurned
Churned
>90 days
@retNever
Never Ordered
@if (Model.CustomerRetention.Any()) {
Retention Breakdown
Customer Retention Detail
@Model.CustomerRetention.Count customers
@foreach (var r in Model.CustomerRetention) { var badgeClass = r.RetentionStatus switch { "Active" => "bg-success", "At Risk" => "bg-warning text-dark", "Lapsing" => "bg-warning text-dark", "Churned" => "bg-danger", _ => "bg-secondary" }; }
Customer Status Last Job Days Since Total Jobs Lifetime Revenue
@r.CustomerName @if (!string.IsNullOrEmpty(r.Email)) {
@r.Email
}
@r.RetentionStatus @(r.LastJobDate.HasValue ? r.LastJobDate.Value.ToString("MMM d, yyyy") : "—") @(r.DaysSinceLastJob >= 0 ? r.DaysSinceLastJob + "d" : "—") @r.TotalJobs @r.LifetimeRevenue.ToString("C0")
} else {

No customer data available.

}

Overall Avg Cycle Time

@Math.Round(Model.OverallAvgCycleDays, 1)d

From creation to completion

Statuses Tracked

@Model.JobCycleTime.Count

With time data

Longest Avg Stage

@if (Model.JobCycleTime.Any()) { var longest = Model.JobCycleTime.OrderByDescending(x => x.AvgDays).First();

@longest.AvgDays d

@longest.StatusName } else {

}
@if (Model.JobCycleTime.Any()) {
Avg Days per Stage
Stage Breakdown
@foreach (var s in Model.JobCycleTime) { }
Stage Avg Days Min Days Max Days Jobs Sampled
@s.StatusName @s.AvgDays.ToString("N1") @s.MinDays.ToString("N1") @s.MaxDays.ToString("N1") @s.JobCount
} else {

Not enough job history data to calculate cycle times yet.

}
@{ var agingOverdue = Model.JobStatusAging.Count(j => j.IsOverdue); var agingAvgDays = Model.JobStatusAging.Any() ? Math.Round(Model.JobStatusAging.Average(j => j.DaysInCurrentStatus), 1) : 0.0; var agingStale = Model.JobStatusAging.Count(j => j.DaysInCurrentStatus >= 7); }

Active Jobs

@Model.JobStatusAging.Count

Currently in progress

Overdue

@agingOverdue

Past due date

Avg Days in Status

@agingAvgDays d

@agingStale stale (≥7 days)
@if (Model.JobStatusAging.Any()) {
Active Jobs by Age
@Model.JobStatusAging.Count jobs
@foreach (var j in Model.JobStatusAging) { var agingBadge = j.DaysInCurrentStatus >= 14 ? "bg-danger" : j.DaysInCurrentStatus >= 7 ? "bg-warning text-dark" : "bg-success"; var priorityBadge = j.PriorityCode switch { "URGENT" => "bg-danger", "RUSH" => "bg-danger", "HIGH" => "bg-warning text-dark", "NORMAL" => "bg-info text-dark", _ => "bg-secondary" }; }
Job # Customer Current Status Priority Days in Status Due Date
@j.JobNumber @j.CustomerName @j.StatusName @j.PriorityName @j.DaysInCurrentStatus d @if (j.DueDate.HasValue) { @j.DueDate.Value.ToString("MMM d, yyyy") @if (j.IsOverdue) { } } else { }
} else {

No active jobs to display.

}
@if (allowAccounting) {
@{ var iadCurrent = Model.InvoiceAgingDetail.Where(i => i.AgingBucket == "Current").ToList(); var iad1to30 = Model.InvoiceAgingDetail.Where(i => i.AgingBucket == "1–30 Days").ToList(); var iad31to60 = Model.InvoiceAgingDetail.Where(i => i.AgingBucket == "31–60 Days").ToList(); var iad61to90 = Model.InvoiceAgingDetail.Where(i => i.AgingBucket == "61–90 Days").ToList(); var iad90plus = Model.InvoiceAgingDetail.Where(i => i.AgingBucket == "90+ Days").ToList(); }
@iadCurrent.Sum(i => i.BalanceDue).ToString("C0")
Current
@iadCurrent.Count invoices
@iad1to30.Sum(i => i.BalanceDue).ToString("C0")
1–30 Days
@iad1to30.Count invoices
@iad31to60.Sum(i => i.BalanceDue).ToString("C0")
31–60 Days
@iad31to60.Count invoices
@iad61to90.Sum(i => i.BalanceDue).ToString("C0")
61–90 Days
@iad61to90.Count invoices
@iad90plus.Sum(i => i.BalanceDue).ToString("C0")
90+ Days
@iad90plus.Count invoices
@if (Model.InvoiceAgingDetail.Any()) {
Outstanding Balance by Aging Bucket
Outstanding Invoice Detail
@Model.InvoiceAgingDetail.Count invoices
@foreach (var inv in Model.InvoiceAgingDetail) { var bucketBadge = inv.AgingBucket switch { "Current" => "bg-success", "1–30 Days" => "bg-warning text-dark", "31–60 Days" => "badge-orange", "61–90 Days" => "bg-danger", _ => "bg-danger" }; }
Invoice # Customer Invoice Date Due Date Total Paid Balance Due Days Overdue Bucket
@inv.InvoiceNumber
@inv.CustomerName
@if (!string.IsNullOrEmpty(inv.CustomerEmail)) {
@inv.CustomerEmail
}
@inv.InvoiceDate.ToString("MMM d, yyyy") @(inv.DueDate.HasValue ? inv.DueDate.Value.ToString("MMM d, yyyy") : "—") @inv.Total.ToString("C") @inv.AmountPaid.ToString("C") @inv.BalanceDue.ToString("C") @if (inv.DaysOverdue > 0) { @inv.DaysOverdue d } else { current } @inv.AgingBucket
} else {

No outstanding invoices.

}
}
@{ var pcTotalPurchased = Model.PowderConsumption.Sum(p => p.TotalPurchasedLbs); var pcTotalConsumed = Model.PowderConsumption.Sum(p => p.TotalConsumedLbs); var pcEfficiency = pcTotalPurchased > 0 ? Math.Round(pcTotalConsumed / pcTotalPurchased * 100, 1) : 0m; }

Total Purchased

@pcTotalPurchased.ToString("N1") lbs

All time

Total Consumed

@pcTotalConsumed.ToString("N1") lbs

Job usage + waste

Utilization Rate

= 60 ? "text-warning" : "text-danger")">@pcEfficiency%

Consumed / Purchased
@if (Model.PowderConsumption.Any()) {
Top 10 Powders — Purchased vs Consumed (lbs)
Powder Detail
@Model.PowderConsumption.Count items
@foreach (var p in Model.PowderConsumption) { var util = p.TotalPurchasedLbs > 0 ? Math.Round(p.TotalConsumedLbs / p.TotalPurchasedLbs * 100, 1) : 0m; }
Item / Color Manufacturer Purchased (lbs) Consumed (lbs) Utilization Jobs Used
@(p.ColorName ?? p.ItemName)
@if (!string.IsNullOrEmpty(p.ColorCode)) {
@p.ColorCode @(!string.IsNullOrEmpty(p.SKU) ? $"· {p.SKU}" : "")
}
@(p.Manufacturer ?? "—") @p.TotalPurchasedLbs.ToString("N1") @p.TotalConsumedLbs.ToString("N1") = 60 ? "bg-warning text-dark" : "bg-danger")"> @util% @p.UsageJobCount
} else {

No inventory transaction data available.

}
@{ var itCritical = Model.InventoryTurnover.Count(i => i.StockStatus == "Critical"); var itLow = Model.InventoryTurnover.Count(i => i.StockStatus == "Low"); var itOverstocked = Model.InventoryTurnover.Count(i => i.StockStatus == "Overstocked"); var itNoUsage = Model.InventoryTurnover.Count(i => i.StockStatus == "No Usage"); var itAvgTurnover = Model.InventoryTurnover.Where(i => i.TurnoverRate > 0).Any() ? Math.Round(Model.InventoryTurnover.Where(i => i.TurnoverRate > 0).Average(i => i.TurnoverRate), 2) : 0.0; }
@itCritical
Critical
<7 days
@itLow
Low
7–30 days
@itOverstocked
Overstocked
>90 days
@itNoUsage
No Usage
@itAvgTurnover.ToString("N2")x
Avg Turnover
Consumed / On Hand
@if (Model.InventoryTurnover.Any()) {
Inventory Turnover Detail
@Model.InventoryTurnover.Count items
@foreach (var it in Model.InventoryTurnover) { var statusBadge = it.StockStatus switch { "Critical" => "bg-danger", "Low" => "bg-warning text-dark", "Overstocked" => "bg-info text-dark", "No Usage" => "bg-secondary", _ => "bg-success" }; }
Item / Color On Hand (lbs) Daily Use (lbs) Days to Stockout Turnover Rate Total Consumed Status
@(it.ColorName ?? it.ItemName)
@if (!string.IsNullOrEmpty(it.SKU)) {
@it.SKU
}
@it.CurrentStockLbs.ToString("N1") @it.DailyConsumptionLbs.ToString("N3") @(it.DaysToStockout >= 9999 ? "∞" : it.DaysToStockout.ToString("N0") + " d") @it.TurnoverRate.ToString("N2")x @it.TotalConsumedLbs.ToString("N1") lbs @it.StockStatus
} else {

No inventory data available.

}
@section Scripts { }