Files
PowderCoatingLogix/src/PowderCoating.Web/Views/Reports/Index.cshtml
T
spouliot e2f9e9ae4f Button consistency sweep + mobile responsiveness patches
- Standardize modal dismiss/cancel buttons to btn-outline-secondary across 70+ views
- Remove btn-sm from page-level Create and Back buttons (Index + Detail pages)
- Fix Edit buttons on Details pages: btn-secondary -> btn-warning
- Fix form Cancel/Back links: btn-secondary -> btn-outline-secondary
- Add 10 CSS patches to site.css for mobile/tablet responsiveness:
  top-navbar overflow prevention, page-header flex-wrap at 575px,
  table action button min-height override, notification dropdown width cap,
  tablet content padding

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 19:04:10 -04:00

3411 lines
173 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.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;
}
<!-- Header with Filter -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-3">
<div class="d-flex align-items-center gap-2">
<a asp-controller="Reports" asp-action="Landing" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
</div>
<div>
<form asp-action="Index" method="get" class="d-flex gap-2">
<select name="months" class="form-select" onchange="this.form.submit()">
@{
var selected3 = Model.SelectedMonths == 3 ? "selected" : "";
var selected6 = Model.SelectedMonths == 6 ? "selected" : "";
var selected12 = Model.SelectedMonths == 12 ? "selected" : "";
var selected24 = Model.SelectedMonths == 24 ? "selected" : "";
}
<option value="3" selected="@selected3">Last 3 Months</option>
<option value="6" selected="@selected6">Last 6 Months</option>
<option value="12" selected="@selected12">Last 12 Months</option>
<option value="24" selected="@selected24">Last 24 Months</option>
</select>
</form>
</div>
</div>
</div>
</div>
<!-- Tabs Navigation -->
<ul class="nav nav-tabs mb-4" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#overview-tab" type="button">
<i class="bi bi-speedometer2 me-1"></i>Overview
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#revenue-tab" type="button">
<i class="bi bi-currency-dollar me-1"></i>Revenue
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#operations-tab" type="button">
<i class="bi bi-gear-fill me-1"></i>Operations
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#customers-tab" type="button">
<i class="bi bi-people-fill me-1"></i>Customers
</button>
</li>
@if (allowAccounting)
{
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#financial-tab" type="button">
<i class="bi bi-receipt me-1"></i>Financial
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#expenses-tab" type="button">
<i class="bi bi-receipt-cutoff me-1"></i>Expenses
</button>
</li>
}
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#powder-tab" type="button">
<i class="bi bi-palette-fill me-1"></i>Powder Usage
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#sales-by-customer-tab" type="button">
<i class="bi bi-people me-1"></i>Sales by Customer
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#customer-retention-tab" type="button">
<i class="bi bi-arrow-repeat me-1"></i>Retention
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#job-cycle-time-tab" type="button">
<i class="bi bi-stopwatch me-1"></i>Cycle Time
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#job-status-aging-tab" type="button">
<i class="bi bi-hourglass-split me-1"></i>Job Aging
</button>
</li>
@if (allowAccounting)
{
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#invoice-aging-detail-tab" type="button">
<i class="bi bi-calendar-x me-1"></i>AR Aging
</button>
</li>
}
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#powder-consumption-tab" type="button">
<i class="bi bi-bar-chart-steps me-1"></i>Powder Consumption
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#inventory-turnover-tab" type="button">
<i class="bi bi-arrow-clockwise me-1"></i>Inventory Turnover
</button>
</li>
</ul>
<!-- Tab Content -->
<div class="tab-content">
<!-- OVERVIEW TAB -->
<div class="tab-pane fade show active" id="overview-tab">
<!-- KPI Cards (8 cards) -->
<div class="row g-3 mb-4">
<div class="col-md-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="text-muted mb-1 small">Total Revenue</p>
<h3 class="mb-0 fw-bold">@Model.TotalRevenue.ToString("C0")</h3>
@if (Model.MonthOverMonthGrowth != 0)
{
<small class="@(Model.MonthOverMonthGrowth > 0 ? "text-success" : "text-danger")">
<i class="bi bi-arrow-@(Model.MonthOverMonthGrowth > 0 ? "up" : "down")"></i>
@Math.Abs(Model.MonthOverMonthGrowth)% MoM
</small>
}
</div>
<div class="rounded-circle p-3" style="background:#d1fae5;">
<i class="bi bi-currency-dollar text-success fs-4"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="text-muted mb-1 small">Active Jobs</p>
<h3 class="mb-0 fw-bold">@Model.ActiveJobsCount</h3>
<small class="text-muted">@Model.CompletedJobsThisMonth completed this month</small>
</div>
<div class="rounded-circle p-3" style="background:#dbeafe;">
<i class="bi bi-briefcase text-primary fs-4"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="text-muted mb-1 small">Quote Win Rate</p>
<h3 class="mb-0 fw-bold">@Model.QuoteWinRate%</h3>
<small class="text-muted">of @Model.TotalQuotes quotes</small>
</div>
<div class="rounded-circle p-3" style="background:#fef3c7;">
<i class="bi bi-graph-up text-warning fs-4"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="text-muted mb-1 small">Active Customers</p>
<h3 class="mb-0 fw-bold">@Model.ActiveCustomersCount</h3>
<small class="text-muted">@Model.CustomerRetentionRate% retention</small>
</div>
<div class="rounded-circle p-3" style="background:#fee2e2;">
<i class="bi bi-people text-danger fs-4"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="text-muted mb-1 small">Avg Job Value</p>
<h3 class="mb-0 fw-bold">@Model.AverageJobValue.ToString("C0")</h3>
</div>
<div class="rounded-circle p-3" style="background:#e0e7ff;">
<i class="bi bi-calculator text-info fs-4"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="text-muted mb-1 small">Appointments This Month</p>
<h3 class="mb-0 fw-bold">@Model.AppointmentsThisMonth</h3>
<small class="text-muted">@Model.AppointmentCompletionRate% completion</small>
</div>
<div class="rounded-circle p-3" style="background:#fce7f3;">
<i class="bi bi-calendar-event fs-4" style="color:#db2777;"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="text-muted mb-1 small">Avg Job Duration</p>
<h3 class="mb-0 fw-bold">@Model.AverageJobDuration.ToString("F1")</h3>
<small class="text-muted">days</small>
</div>
<div class="rounded-circle p-3" style="background:#ddd6fe;">
<i class="bi bi-clock-history fs-4" style="color:#7c3aed;"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="text-muted mb-1 small">Low Stock Items</p>
<h3 class="mb-0 fw-bold @(Model.LowStockItems.Any() ? "text-warning" : "text-success")">
@Model.LowStockItems.Count
</h3>
<small class="text-muted">need reordering</small>
</div>
<div class="rounded-circle p-3" style="background:#fed7aa;">
<i class="bi bi-box-seam fs-4" style="color:#ea580c;"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Charts Row 1 -->
<div class="row g-3 mb-4">
<div class="col-lg-8">
<div class="card border-0 shadow-sm h-100">
<div class="card-header border-0 bg-body py-3">
<h5 class="mb-0 fw-semibold">Revenue Trend</h5>
</div>
<div class="card-body">
<canvas id="overviewRevenueChart" height="90"></canvas>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header border-0 bg-body py-3">
<h5 class="mb-0 fw-semibold">Job Status</h5>
</div>
<div class="card-body d-flex align-items-center justify-content-center">
@if (Model.JobsByStatus.Any())
{
<canvas id="jobStatusDonut"></canvas>
}
else
{
<p class="text-muted">No data</p>
}
</div>
</div>
</div>
</div>
<!-- Charts Row 2 -->
<div class="row g-3">
<div class="col-lg-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header border-0 bg-body py-3">
<h5 class="mb-0 fw-semibold">Top Customers</h5>
</div>
<div class="card-body">
@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;
<div class="mb-3">
<div class="d-flex justify-content-between mb-1">
<span class="fw-medium">@customer.Name</span>
<span class="text-muted">@customer.Revenue.ToString("C0")</span>
</div>
<div class="progress" style="height:8px;">
<div class="progress-bar bg-primary" style="width:@pct.ToString("F1")%"></div>
</div>
</div>
}
}
else
{
<p class="text-muted text-center py-4">No customer data yet</p>
}
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header border-0 bg-body py-3">
<h5 class="mb-0 fw-semibold">Equipment Health</h5>
</div>
<div class="card-body">
@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"
};
<div class="mb-3">
<div class="d-flex justify-content-between mb-1">
<span class="fw-medium">@status</span>
<span class="text-muted">@count</span>
</div>
<div class="progress" style="height:8px;">
<div class="progress-bar @barClass" style="width:@pct.ToString("F1")%"></div>
</div>
</div>
}
}
else
{
<p class="text-muted text-center py-4">No equipment data yet</p>
}
</div>
</div>
</div>
</div>
</div>
<!-- REVENUE TAB -->
<div class="tab-pane fade" id="revenue-tab">
<!-- Revenue Charts -->
<div class="row g-3 mb-4">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-header border-0 bg-body py-3">
<h5 class="mb-0 fw-semibold">Revenue & Job Count Trend</h5>
</div>
<div class="card-body">
<canvas id="revenueJobTrendChart" height="80"></canvas>
</div>
</div>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-lg-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header border-0 bg-body py-3">
<h5 class="mb-0 fw-semibold">Average Order Value Trend</h5>
</div>
<div class="card-body">
<canvas id="avgOrderValueChart" height="120"></canvas>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header border-0 bg-body py-3">
<h5 class="mb-0 fw-semibold">Revenue by Customer Type</h5>
</div>
<div class="card-body d-flex align-items-center justify-content-center">
@if (Model.RevenueByCustomerType.Any())
{
<canvas id="revenueByCustomerTypeChart"></canvas>
}
else
{
<p class="text-muted">No data</p>
}
</div>
</div>
</div>
</div>
<div class="row g-3">
<div class="col-lg-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header border-0 bg-body py-3">
<h5 class="mb-0 fw-semibold">Revenue by Job Priority</h5>
</div>
<div class="card-body">
@if (Model.RevenueByPriority.Any())
{
<canvas id="revenueByPriorityChart" height="120"></canvas>
}
else
{
<p class="text-muted text-center py-4">No data</p>
}
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header border-0 bg-body py-3">
<h5 class="mb-0 fw-semibold">Top 10 Customers by Revenue</h5>
</div>
<div class="card-body p-0">
@if (Model.TopCustomers.Any())
{
<!-- Desktop Table View -->
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Customer</th>
<th class="text-center">Jobs</th>
<th class="text-end">Revenue</th>
</tr>
</thead>
<tbody>
@foreach (var customer in Model.TopCustomers)
{
<tr>
<td><a asp-controller="Customers" asp-action="Details" asp-route-id="@customer.Id" class="text-decoration-none fw-medium">@customer.Name</a></td>
<td class="text-center"><span class="badge bg-primary">@customer.JobCount</span></td>
<td class="text-end fw-semibold text-success">@customer.Revenue.ToString("C0")</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Mobile Card View -->
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var customer in Model.TopCustomers)
{
<a asp-controller="Customers" asp-action="Details" asp-route-id="@customer.Id" class="mobile-data-card text-decoration-none">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<i class="bi bi-person-circle"></i>
</div>
<div class="mobile-card-title">
<h6>@customer.Name</h6>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Jobs</span>
<span class="mobile-card-value"><span class="badge bg-primary">@customer.JobCount</span></span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Revenue</span>
<span class="mobile-card-value fw-semibold text-success">@customer.Revenue.ToString("C0")</span>
</div>
</div>
</a>
}
</div>
</div>
}
else
{
<div class="p-4 text-center">
<p class="text-muted mb-0">No customer revenue data yet</p>
</div>
}
</div>
</div>
</div>
</div>
</div>
<!-- OPERATIONS TAB -->
<div class="tab-pane fade" id="operations-tab">
<!-- Jobs & Appointments -->
<div class="row g-3 mb-4">
<div class="col-lg-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header border-0 bg-body py-3">
<h5 class="mb-0 fw-semibold">Jobs by Priority</h5>
</div>
<div class="card-body d-flex align-items-center justify-content-center">
@if (Model.ActiveJobsByPriority.Any())
{
<canvas id="jobsByPriorityChart"></canvas>
}
else
{
<p class="text-muted">No active jobs</p>
}
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header border-0 bg-body py-3">
<h5 class="mb-0 fw-semibold">Appointments by Type</h5>
</div>
<div class="card-body d-flex align-items-center justify-content-center">
@if (Model.AppointmentsByType.Any())
{
<canvas id="appointmentsByTypeChart"></canvas>
}
else
{
<p class="text-muted">No appointments</p>
}
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header border-0 bg-body py-3">
<h5 class="mb-0 fw-semibold">Appointments by Day</h5>
</div>
<div class="card-body">
@if (Model.AppointmentsByDayOfWeek.Any())
{
<canvas id="appointmentsByDayChart" height="120"></canvas>
}
else
{
<p class="text-muted text-center py-4">No data</p>
}
</div>
</div>
</div>
</div>
<!-- Worker Performance -->
<div class="row g-3 mb-4">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-header border-0 bg-body py-3">
<h5 class="mb-0 fw-semibold">Worker Performance</h5>
</div>
<div class="card-body p-0">
@if (Model.WorkerStats.Any())
{
<!-- Desktop Table View -->
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Worker</th>
<th>Role</th>
<th class="text-center">Jobs Assigned</th>
<th class="text-center">Jobs Completed</th>
<th class="text-center">Appointments</th>
<th class="text-end">Completion Rate</th>
</tr>
</thead>
<tbody>
@foreach (var worker in Model.WorkerStats)
{
<tr>
<td class="fw-medium">@worker.Name</td>
<td><span class="badge bg-secondary">@worker.Role</span></td>
<td class="text-center">@worker.JobsAssigned</td>
<td class="text-center"><span class="badge bg-success">@worker.JobsCompleted</span></td>
<td class="text-center">@worker.AppointmentsAssigned</td>
<td class="text-end">
<div class="d-flex align-items-center justify-content-end gap-2">
<div class="progress" style="width:60px; height:6px;">
<div class="progress-bar bg-primary" style="width:@worker.CompletionRate%"></div>
</div>
<span class="small">@worker.CompletionRate%</span>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Mobile Card View -->
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var worker in Model.WorkerStats)
{
<div class="mobile-data-card">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);">
<i class="bi bi-person-badge"></i>
</div>
<div class="mobile-card-title">
<h6>@worker.Name</h6>
<small><span class="badge bg-secondary">@worker.Role</span></small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Jobs Assigned</span>
<span class="mobile-card-value">@worker.JobsAssigned</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Jobs Completed</span>
<span class="mobile-card-value"><span class="badge bg-success">@worker.JobsCompleted</span></span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Appointments</span>
<span class="mobile-card-value">@worker.AppointmentsAssigned</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Completion Rate</span>
<span class="mobile-card-value">
<div class="d-flex align-items-center justify-content-end gap-2">
<div class="progress" style="width:60px; height:6px;">
<div class="progress-bar bg-primary" style="width:@worker.CompletionRate%"></div>
</div>
<span class="small">@worker.CompletionRate%</span>
</div>
</span>
</div>
</div>
</div>
}
</div>
</div>
}
else
{
<div class="p-4 text-center">
<p class="text-muted mb-0">No worker data yet</p>
</div>
}
</div>
</div>
</div>
</div>
<!-- Low Stock -->
<div class="row g-3">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-header border-0 bg-body py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0 fw-semibold">Low Stock Alert</h5>
@if (Model.LowStockItems.Any())
{
<span class="badge bg-danger">@Model.LowStockItems.Count items</span>
}
</div>
<div class="card-body p-0">
@if (Model.LowStockItems.Any())
{
<!-- Desktop Table View -->
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Item</th>
<th class="text-end">On Hand</th>
<th class="text-end">Reorder Point</th>
<th class="text-end">Status</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.LowStockItems)
{
<tr>
<td>
<span class="fw-medium">@item.Name</span>
@if (!string.IsNullOrEmpty(item.ColorName))
{
<br /><small class="text-muted">@item.ColorName</small>
}
</td>
<td class="text-end">@item.QuantityOnHand.ToString("F1") @item.UnitOfMeasure</td>
<td class="text-end text-muted">@item.ReorderPoint.ToString("F1") @item.UnitOfMeasure</td>
<td class="text-end">
<span class="badge @(item.QuantityOnHand == 0 ? "bg-danger" : "bg-warning text-dark")">
@(item.QuantityOnHand == 0 ? "Out of Stock" : "Low Stock")
</span>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Mobile Card View -->
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var item in Model.LowStockItems)
{
<a asp-controller="Inventory" asp-action="Details" asp-route-id="@item.Id" class="mobile-data-card text-decoration-none">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: @(item.QuantityOnHand == 0 ? "linear-gradient(135deg, #eb3349 0%, #f45c43 100%)" : "linear-gradient(135deg, #f093fb 0%, #f5576c 100%)");">
<i class="bi bi-box-seam"></i>
</div>
<div class="mobile-card-title">
<h6>@item.Name</h6>
@if (!string.IsNullOrEmpty(item.ColorName))
{
<small>@item.ColorName</small>
}
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">On Hand</span>
<span class="mobile-card-value">@item.QuantityOnHand.ToString("F1") @item.UnitOfMeasure</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Reorder Point</span>
<span class="mobile-card-value text-muted">@item.ReorderPoint.ToString("F1") @item.UnitOfMeasure</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value">
<span class="badge @(item.QuantityOnHand == 0 ? "bg-danger" : "bg-warning text-dark")">
@(item.QuantityOnHand == 0 ? "Out of Stock" : "Low Stock")
</span>
</span>
</div>
</div>
</a>
}
</div>
</div>
}
else
{
<div class="p-4 text-center">
<i class="bi bi-check-circle text-success" style="font-size:2rem;"></i>
<p class="text-muted mt-2 mb-0">All items sufficiently stocked</p>
</div>
}
</div>
</div>
</div>
</div>
</div>
<!-- CUSTOMERS TAB -->
<div class="tab-pane fade" id="customers-tab">
<!-- Customer Metrics -->
<div class="row g-3 mb-4">
<div class="col-lg-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header border-0 bg-body py-3">
<h5 class="mb-0 fw-semibold">New Customers</h5>
</div>
<div class="card-body">
<canvas id="newCustomersChart" height="120"></canvas>
</div>
</div>
</div>
<div class="col-lg-8">
<div class="card border-0 shadow-sm h-100">
<div class="card-header border-0 bg-body py-3">
<h5 class="mb-0 fw-semibold">Quote Conversion Funnel</h5>
</div>
<div class="card-body">
@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)));
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h3 class="mb-0">@Model.QuoteFunnel.ConversionRate%</h3>
<small class="text-muted">Overall Conversion Rate</small>
</div>
<div class="text-muted small">
<i class="bi bi-x-circle me-1"></i>Rejected: @Model.QuoteFunnel.Rejected &nbsp;&nbsp;
<i class="bi bi-calendar-x me-1"></i>Expired: @Model.QuoteFunnel.Expired
</div>
</div>
@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;
<div class="mb-3">
<div class="d-flex justify-content-between mb-2">
<div>
<span class="fw-medium">@stage</span>
@if (dropoffPct > 0)
{
<small class="text-danger ms-2"><i class="bi bi-arrow-down"></i>@dropoffPct% drop</small>
}
</div>
<span class="fw-bold">@count</span>
</div>
<div class="progress" style="height:20px;">
<div class="progress-bar bg-@color" style="width:@pct.ToString("F1")%">
@if (pct > 15)
{
<span class="px-2">@count quotes</span>
}
</div>
</div>
</div>
}
}
else
{
<p class="text-muted text-center py-4">No quote data yet</p>
}
</div>
</div>
</div>
</div>
<!-- Customer Lifetime Value -->
<div class="row g-3">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-header border-0 bg-body py-3">
<h5 class="mb-0 fw-semibold">Top 10 Customers by Lifetime Value</h5>
</div>
<div class="card-body p-0">
@if (Model.CustomerLifetimeValue.Any())
{
<!-- Desktop Table View -->
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Customer</th>
<th class="text-center">Total Jobs</th>
<th class="text-end">Avg Order Value</th>
<th class="text-end">Lifetime Revenue</th>
<th class="text-center">First Job</th>
<th class="text-center">Last Job</th>
</tr>
</thead>
<tbody>
@foreach (var customer in Model.CustomerLifetimeValue)
{
<tr>
<td class="fw-medium">@customer.CustomerName</td>
<td class="text-center"><span class="badge bg-primary">@customer.JobCount</span></td>
<td class="text-end">@customer.AvgOrderValue.ToString("C0")</td>
<td class="text-end fw-bold text-success">@customer.TotalRevenue.ToString("C0")</td>
<td class="text-center small text-muted">@customer.FirstJobDate.ToString("MMM yyyy")</td>
<td class="text-center small text-muted">@customer.LastJobDate.ToString("MMM yyyy")</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Mobile Card View -->
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var customer in Model.CustomerLifetimeValue)
{
<div class="mobile-data-card">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);">
<i class="bi bi-award"></i>
</div>
<div class="mobile-card-title">
<h6>@customer.CustomerName</h6>
<small><span class="badge bg-primary">@customer.JobCount Jobs</span></small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Lifetime Revenue</span>
<span class="mobile-card-value fw-bold text-success">@customer.TotalRevenue.ToString("C0")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Avg Order Value</span>
<span class="mobile-card-value">@customer.AvgOrderValue.ToString("C0")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">First Job</span>
<span class="mobile-card-value text-muted">@customer.FirstJobDate.ToString("MMM yyyy")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Last Job</span>
<span class="mobile-card-value text-muted">@customer.LastJobDate.ToString("MMM yyyy")</span>
</div>
</div>
</div>
}
</div>
</div>
}
else
{
<div class="p-4 text-center">
<p class="text-muted mb-0">No customer lifetime value data yet</p>
</div>
}
</div>
</div>
</div>
</div>
</div>
@if (allowAccounting)
{
<!-- FINANCIAL TAB -->
<div class="tab-pane fade" id="financial-tab">
<!-- AI Financial Summary Card -->
<div class="row mb-4">
<div class="col-12">
<div class="card border-0 shadow-sm" id="aiSummaryCard">
<div class="card-body d-flex align-items-start gap-3">
<div class="rounded-circle p-2 flex-shrink-0" style="background:#f3e8ff;">
<i class="bi bi-stars text-purple fs-5" style="color:#7c3aed;"></i>
</div>
<div class="flex-grow-1">
<div class="d-flex align-items-center gap-2 mb-1">
<h6 class="mb-0 fw-semibold">AI Financial Summary</h6>
<span class="badge bg-secondary" id="aiSummarySentimentBadge" style="display:none;"></span>
</div>
<div id="aiSummaryContent" class="text-muted small">
Click "Generate" to get a plain-English summary of your financial health.
</div>
<ul id="aiSummaryBullets" class="mb-0 mt-2 ps-3 small" style="display:none;"></ul>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary flex-shrink-0" id="aiSummaryBtn">
<i class="bi bi-stars me-1"></i>Generate
</button>
</div>
</div>
</div>
</div>
<!-- KPI Cards -->
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<p class="text-muted mb-1 small">Total Invoiced</p>
<h3 class="mb-0 fw-bold">@Model.TotalInvoiced.ToString("C0")</h3>
</div>
<div class="rounded-circle p-2" style="background:#dbeafe;">
<i class="bi bi-receipt text-primary" style="font-size:1.2rem;"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<p class="text-muted mb-1 small">Collected</p>
<h3 class="mb-0 fw-bold text-success">@Model.TotalCollected.ToString("C0")</h3>
</div>
<div class="rounded-circle p-2" style="background:#d1fae5;">
<i class="bi bi-check-circle text-success" style="font-size:1.2rem;"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<p class="text-muted mb-1 small">Outstanding</p>
<h3 class="mb-0 fw-bold text-warning">@Model.TotalOutstanding.ToString("C0")</h3>
<small class="text-muted">@Model.InvoicesDraftCount draft</small>
</div>
<div class="rounded-circle p-2" style="background:#fef3c7;">
<i class="bi bi-hourglass-split text-warning" style="font-size:1.2rem;"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<p class="text-muted mb-1 small">Overdue</p>
<h3 class="mb-0 fw-bold text-danger">@Model.TotalOverdue.ToString("C0")</h3>
<small class="text-muted">@Model.InvoicesOverdueCount invoice@(Model.InvoicesOverdueCount != 1 ? "s" : "")</small>
</div>
<div class="rounded-circle p-2" style="background:#fee2e2;">
<i class="bi bi-exclamation-triangle text-danger" style="font-size:1.2rem;"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row g-4 mb-4">
<!-- Monthly Invoiced vs Collected Chart -->
<div class="col-lg-8">
<div class="card border-0 shadow-sm">
<div class="card-header border-0 bg-body py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0 fw-semibold">Monthly: Invoiced vs Collected</h5>
<div class="d-flex gap-3 small">
<span><span class="d-inline-block me-1" style="width:12px;height:12px;background:#4f46e5;border-radius:2px;"></span>Invoiced</span>
<span><span class="d-inline-block me-1" style="width:12px;height:12px;background:#10b981;border-radius:2px;"></span>Collected</span>
</div>
</div>
<div class="card-body">
<canvas id="financialMonthlyChart" height="120"></canvas>
</div>
</div>
</div>
<!-- Avg Days to Payment -->
<div class="col-lg-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header border-0 bg-body py-3">
<h5 class="mb-0 fw-semibold">Payment Performance</h5>
</div>
<div class="card-body">
<div class="text-center py-3">
<div class="display-4 fw-bold text-primary">@Model.AvgDaysToPayment.ToString("F0")</div>
<p class="text-muted mb-0">Avg. Days to Payment</p>
</div>
<hr />
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Paid Invoices</span>
<span class="fw-semibold text-success">@Model.InvoicesPaidCount</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Overdue Invoices</span>
<span class="fw-semibold text-danger">@Model.InvoicesOverdueCount</span>
</div>
<div class="d-flex justify-content-between">
<span class="text-muted">Draft Invoices</span>
<span class="fw-semibold text-secondary">@Model.InvoicesDraftCount</span>
</div>
</div>
</div>
</div>
</div>
<div class="row g-4 mb-4">
<!-- AR Aging -->
<div class="col-lg-6">
<div class="card border-0 shadow-sm">
<div class="card-header border-0 bg-body py-3">
<h5 class="mb-0 fw-semibold">AR Aging</h5>
</div>
<div class="card-body p-0">
@if (Model.AgingBuckets.Any(b => b.Count > 0))
{
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Age</th>
<th class="text-center">Invoices</th>
<th class="text-end">Amount</th>
</tr>
</thead>
<tbody>
@{
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";
<tr class="@rowClass">
<td>@bucket.Label</td>
<td class="text-center">@bucket.Count</td>
<td class="text-end fw-semibold">@bucket.Amount.ToString("C0")</td>
</tr>
}
</tbody>
<tfoot class="table-light">
<tr>
<th>Total Outstanding</th>
<th class="text-center">@Model.AgingBuckets.Sum(b => b.Count)</th>
<th class="text-end">@Model.AgingBuckets.Sum(b => b.Amount).ToString("C0")</th>
</tr>
</tfoot>
</table>
}
else
{
<div class="p-4 text-center text-muted">
<i class="bi bi-check-circle text-success" style="font-size:2rem;"></i>
<p class="mt-2 mb-0">No outstanding invoices</p>
</div>
}
</div>
</div>
</div>
<!-- Top Outstanding Customers -->
<div class="col-lg-6">
<div class="card border-0 shadow-sm">
<div class="card-header border-0 bg-body py-3">
<h5 class="mb-0 fw-semibold">Top Outstanding Customers</h5>
</div>
<div class="card-body p-0">
@if (Model.TopOutstandingCustomers.Any())
{
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Customer</th>
<th class="text-center">Invoices</th>
<th class="text-end">Balance</th>
<th style="width:100px"></th>
</tr>
</thead>
<tbody>
@foreach (var oc in Model.TopOutstandingCustomers)
{
<tr>
<td>@oc.CustomerName</td>
<td class="text-center">@oc.OpenInvoiceCount</td>
<td class="text-end fw-semibold text-danger">@oc.OutstandingBalance.ToString("C0")</td>
<td class="text-end">
<button type="button" class="btn btn-xs btn-sm btn-outline-primary py-0 px-2 draft-email-btn"
data-customer="@oc.CustomerName"
data-amount="@oc.OutstandingBalance.ToString("F2")"
data-invoices="@oc.OpenInvoiceCount"
title="Draft follow-up email">
<i class="bi bi-envelope me-1"></i>Draft Email
</button>
</td>
</tr>
}
</tbody>
</table>
}
else
{
<div class="p-4 text-center text-muted">
<i class="bi bi-people" style="font-size:2rem;"></i>
<p class="mt-2 mb-0">No outstanding customer balances</p>
</div>
}
</div>
</div>
</div>
</div>
<!-- Recent Payments -->
<div class="row g-4">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-header border-0 bg-body py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0 fw-semibold">Recent Payments</h5>
<a asp-controller="Invoices" asp-action="Index" class="btn btn-sm btn-outline-primary">View All Invoices</a>
</div>
<div class="card-body p-0">
@if (Model.RecentPayments.Any())
{
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Date</th>
<th>Invoice #</th>
<th>Customer</th>
<th>Method</th>
<th class="text-end">Amount</th>
</tr>
</thead>
<tbody>
@foreach (var pmt in Model.RecentPayments)
{
<tr>
<td class="text-muted small">@pmt.PaymentDate.ToString("MM/dd/yyyy")</td>
<td><strong>@pmt.InvoiceNumber</strong></td>
<td>@pmt.CustomerName</td>
<td><span class="badge bg-secondary">@pmt.PaymentMethod</span></td>
<td class="text-end fw-semibold text-success">@pmt.Amount.ToString("C")</td>
</tr>
}
</tbody>
</table>
</div>
}
else
{
<div class="p-4 text-center text-muted">
<i class="bi bi-credit-card" style="font-size:2rem;"></i>
<p class="mt-2 mb-0">No payments recorded yet</p>
</div>
}
</div>
</div>
</div>
</div>
<!-- Financial Report Links -->
<div class="row g-3 mt-2">
<div class="col-12">
<h6 class="text-muted text-uppercase small fw-semibold mb-3"><i class="bi bi-file-earmark-text me-1"></i>Dedicated Financial Reports</h6>
</div>
<div class="col-sm-6 col-lg-3">
<a asp-controller="Reports" asp-action="ProfitAndLoss" class="card border-0 shadow-sm text-decoration-none h-100">
<div class="card-body d-flex align-items-center gap-3">
<div class="rounded-circle p-2" style="background:#dcfce7;"><i class="bi bi-file-earmark-bar-graph text-success fs-5"></i></div>
<div>
<div class="fw-semibold">Profit &amp; Loss</div>
<div class="text-muted small">Income statement</div>
</div>
<i class="bi bi-arrow-right ms-auto text-muted"></i>
</div>
</a>
</div>
<div class="col-sm-6 col-lg-3">
<a asp-controller="Reports" asp-action="BalanceSheet" class="card border-0 shadow-sm text-decoration-none h-100">
<div class="card-body d-flex align-items-center gap-3">
<div class="rounded-circle p-2" style="background:#dbeafe;"><i class="bi bi-bank text-primary fs-5"></i></div>
<div>
<div class="fw-semibold">Balance Sheet</div>
<div class="text-muted small">Assets, liabilities &amp; equity</div>
</div>
<i class="bi bi-arrow-right ms-auto text-muted"></i>
</div>
</a>
</div>
<div class="col-sm-6 col-lg-3">
<a asp-controller="Reports" asp-action="ArAging" class="card border-0 shadow-sm text-decoration-none h-100">
<div class="card-body d-flex align-items-center gap-3">
<div class="rounded-circle p-2" style="background:#fef3c7;"><i class="bi bi-clock-history text-warning fs-5"></i></div>
<div>
<div class="fw-semibold">AR Aging</div>
<div class="text-muted small">Outstanding receivables</div>
</div>
<i class="bi bi-arrow-right ms-auto text-muted"></i>
</div>
</a>
</div>
<div class="col-sm-6 col-lg-3">
<a asp-controller="Reports" asp-action="SalesAndIncome" class="card border-0 shadow-sm text-decoration-none h-100">
<div class="card-body d-flex align-items-center gap-3">
<div class="rounded-circle p-2" style="background:#f3e8ff;"><i class="bi bi-currency-dollar text-purple fs-5" style="color:#7c3aed;"></i></div>
<div>
<div class="fw-semibold">Sales &amp; Income</div>
<div class="text-muted small">Revenue by customer &amp; period</div>
</div>
<i class="bi bi-arrow-right ms-auto text-muted"></i>
</div>
</a>
</div>
</div>
</div>
<!-- EXPENSES TAB -->
<div class="tab-pane fade" id="expenses-tab">
<!-- KPI Cards -->
<div class="row g-4 mb-4">
<div class="col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<p class="text-muted small mb-1">Total Bills (All Time)</p>
<p class="h4 fw-bold mb-0">@Model.TotalBilled.ToString("C0")</p>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<p class="text-muted small mb-1">Total Bills Paid</p>
<p class="h4 fw-bold text-success mb-0">@Model.TotalBillsPaid.ToString("C0")</p>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<p class="text-muted small mb-1">AP Outstanding</p>
<p class="h4 fw-bold text-danger mb-0">@Model.TotalApOutstanding.ToString("C0")</p>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<p class="text-muted small mb-1">Direct Expenses (All Time)</p>
<p class="h4 fw-bold text-warning mb-0">@Model.TotalDirectExpenses.ToString("C0")</p>
</div>
</div>
</div>
</div>
<!-- P&L Chart + Expenses by Account -->
<div class="row g-4 mb-4">
<div class="col-lg-8">
<div class="card border-0 shadow-sm h-100">
<div class="card-header border-0 bg-body py-3">
<h5 class="mb-0 fw-semibold">Profit & Loss — Revenue vs Expenses</h5>
</div>
<div class="card-body">
<canvas id="plChart" height="130"></canvas>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header border-0 bg-body py-3">
<h5 class="mb-0 fw-semibold">Expenses by Account</h5>
</div>
<div class="card-body">
@if (Model.ExpensesByAccount.Any())
{
<canvas id="expByAccountChart" height="200"></canvas>
}
else
{
<p class="text-muted text-center mt-4">No expense data yet.</p>
}
</div>
</div>
</div>
</div>
<!-- AP Aging + Vendor Spend -->
<div class="row g-4 mb-4">
<div class="col-lg-5">
<div class="card border-0 shadow-sm h-100">
<div class="card-header border-0 bg-body py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0 fw-semibold">AP Aging</h5>
<a asp-controller="Bills" asp-action="Index" class="btn btn-sm btn-outline-primary">View Bills</a>
</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr>
<th>Bucket</th>
<th class="text-center">Count</th>
<th class="text-end">Balance</th>
</tr>
</thead>
<tbody>
@foreach (var bucket in Model.ApAgingBuckets)
{
<tr>
<td>@bucket.Label</td>
<td class="text-center">@bucket.Count</td>
<td class="text-end @(bucket.Amount > 0 ? "text-danger fw-medium" : "text-muted")">
@bucket.Amount.ToString("C")
</td>
</tr>
}
</tbody>
<tfoot class="table-light fw-bold">
<tr>
<td>Total</td>
<td class="text-center">@Model.ApAgingBuckets.Sum(b => b.Count)</td>
<td class="text-end text-danger">@Model.ApAgingBuckets.Sum(b => b.Amount).ToString("C")</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<div class="col-lg-7">
<div class="card border-0 shadow-sm h-100">
<div class="card-header border-0 bg-body py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0 fw-semibold">Top Vendor Spend</h5>
<a asp-controller="Vendors" asp-action="Index" class="btn btn-sm btn-outline-primary">View Vendors</a>
</div>
<div class="card-body p-0">
@if (Model.VendorSpend.Any())
{
<table class="table table-sm mb-0">
<thead class="table-light">
<tr>
<th>Vendor</th>
<th class="text-center">Bills</th>
<th class="text-end">Billed</th>
<th class="text-end">Paid</th>
<th class="text-end">Balance</th>
</tr>
</thead>
<tbody>
@foreach (var v in Model.VendorSpend)
{
<tr>
<td class="fw-medium">@v.VendorName</td>
<td class="text-center">@v.BillCount</td>
<td class="text-end">@v.TotalBilled.ToString("C")</td>
<td class="text-end text-success">@v.TotalPaid.ToString("C")</td>
<td class="text-end @(v.BalanceDue > 0 ? "text-danger fw-medium" : "text-muted")">
@v.BalanceDue.ToString("C")
</td>
</tr>
}
</tbody>
</table>
}
else
{
<p class="text-muted text-center p-4">No vendor bills recorded yet.</p>
}
</div>
</div>
</div>
</div>
<!-- Expenses by Account Detail -->
@if (Model.ExpensesByAccount.Any())
{
<div class="card border-0 shadow-sm mb-4">
<div class="card-header border-0 bg-body py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0 fw-semibold">Expense Breakdown by Account</h5>
<a asp-controller="Expenses" asp-action="Index" class="btn btn-sm btn-outline-primary">View All Expenses</a>
</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr>
<th>Account</th>
<th class="text-center">Transactions</th>
<th class="text-end">Total</th>
</tr>
</thead>
<tbody>
@foreach (var acct in Model.ExpensesByAccount)
{
<tr>
<td>@acct.AccountName</td>
<td class="text-center">@acct.Count</td>
<td class="text-end fw-medium">@acct.Amount.ToString("C")</td>
</tr>
}
</tbody>
<tfoot class="table-light fw-bold">
<tr>
<td>Total</td>
<td class="text-center">@Model.ExpensesByAccount.Sum(e => e.Count)</td>
<td class="text-end">@Model.ExpensesByAccount.Sum(e => e.Amount).ToString("C")</td>
</tr>
</tfoot>
</table>
</div>
</div>
}
</div>
} <!-- /allowAccounting: Financial + Expenses tabs -->
<!-- POWDER USAGE TAB -->
<div class="tab-pane fade" id="powder-tab">
<!-- KPI Cards -->
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center">
<div class="text-muted small mb-1">Total Powder Used</div>
<div class="fs-4 fw-bold">@Model.TotalPowderUsedLbs.ToString("N1") <span class="fs-6 fw-normal text-muted">lbs</span></div>
<div class="text-muted small">last @Model.SelectedMonths months</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center">
<div class="text-muted small mb-1">Powder Cost</div>
<div class="fs-4 fw-bold">@Model.TotalPowderCost.ToString("C0")</div>
<div class="text-muted small">last @Model.SelectedMonths months</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center">
<div class="text-muted small mb-1">Jobs Tracked</div>
<div class="fs-4 fw-bold">@Model.TotalJobsWithPowderUsage</div>
<div class="text-muted small">with powder usage</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center">
<div class="text-muted small mb-1">Colors Used</div>
<div class="fs-4 fw-bold">@Model.ColorsUsedCount</div>
<div class="text-muted small">distinct powders</div>
</div>
</div>
</div>
</div>
<div class="row g-4 mb-4">
<!-- Top Colors Chart -->
<div class="col-lg-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header border-0 bg-body py-3">
<h5 class="mb-0 fw-semibold">Top Colors by Usage (lbs)</h5>
</div>
<div class="card-body">
@if (Model.TopColorsByUsage.Any())
{
<canvas id="topColorsChart" height="300"></canvas>
}
else
{
<div class="text-center text-muted py-5">
<i class="bi bi-palette" style="font-size:2rem;"></i>
<p class="mt-2 mb-0">No powder usage recorded yet</p>
<small>Usage is tracked when jobs are completed</small>
</div>
}
</div>
</div>
</div>
<!-- Monthly Consumption Trend -->
<div class="col-lg-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header border-0 bg-body py-3">
<h5 class="mb-0 fw-semibold">Monthly Consumption Trend</h5>
</div>
<div class="card-body">
<canvas id="powderTrendChart" height="300"></canvas>
</div>
</div>
</div>
</div>
<!-- Top Colors Table -->
@if (Model.TopColorsByUsage.Any())
{
<div class="card border-0 shadow-sm">
<div class="card-header border-0 bg-body py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0 fw-semibold">Color Usage Detail</h5>
<span class="text-muted small">Last @Model.SelectedMonths months</span>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Color / SKU</th>
<th>Manufacturer</th>
<th class="text-end">Lbs Used</th>
<th class="text-end">Est. Cost</th>
<th class="text-center">Jobs</th>
<th class="text-end">Avg / Job</th>
</tr>
</thead>
<tbody>
@{ 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;
<tr>
<td>
<div class="fw-medium">@color.DisplayLabel</div>
<div class="text-muted small">@color.SKU</div>
</td>
<td class="text-muted small">@(color.Manufacturer ?? "—")</td>
<td class="text-end">
<div>@color.TotalLbsUsed.ToString("N1") lbs</div>
<div class="progress mt-1" style="height:4px; min-width:80px;">
<div class="progress-bar bg-primary" style="width:@pct.ToString("F0")%"></div>
</div>
</td>
<td class="text-end">@color.TotalCost.ToString("C")</td>
<td class="text-center">@color.JobCount</td>
<td class="text-end text-muted">@avgPerJob.ToString("N1") lbs</td>
</tr>
}
</tbody>
<tfoot class="table-light fw-bold">
<tr>
<td colspan="2">Total</td>
<td class="text-end">@Model.TotalPowderUsedLbs.ToString("N1") lbs</td>
<td class="text-end">@Model.TotalPowderCost.ToString("C")</td>
<td class="text-center">@Model.TotalJobsWithPowderUsage</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
</div>
}
</div>
<!-- SALES BY CUSTOMER TAB -->
<div class="tab-pane fade" id="sales-by-customer-tab">
<!-- KPI Cards -->
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="text-muted mb-1 small">Total Invoiced</p>
<h3 class="mb-0 fw-bold">@Model.SalesByCustomerTotalInvoiced.ToString("C0")</h3>
<small class="text-muted">Across all customers</small>
</div>
<div class="rounded-circle p-3" style="background:#d1fae5;">
<i class="bi bi-currency-dollar text-success fs-4"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="text-muted mb-1 small">Total Collected</p>
<h3 class="mb-0 fw-bold">@Model.SalesByCustomerTotalPaid.ToString("C0")</h3>
<small class="text-muted">@((Model.SalesByCustomerTotalInvoiced > 0 ? Math.Round(Model.SalesByCustomerTotalPaid / Model.SalesByCustomerTotalInvoiced * 100, 1) : 0).ToString("N1"))% collection rate</small>
</div>
<div class="rounded-circle p-3" style="background:#dbeafe;">
<i class="bi bi-check-circle text-primary fs-4"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="text-muted mb-1 small">Active Customers</p>
<h3 class="mb-0 fw-bold">@Model.SalesByCustomer.Count</h3>
<small class="text-muted">Avg @((Model.SalesByCustomer.Count > 0 ? Model.SalesByCustomerTotalInvoiced / Model.SalesByCustomer.Count : 0).ToString("C0")) per customer</small>
</div>
<div class="rounded-circle p-3" style="background:#ede9fe;">
<i class="bi bi-people text-purple fs-4" style="color:#7c3aed;"></i>
</div>
</div>
</div>
</div>
</div>
</div>
@if (Model.SalesByCustomer.Any())
{
<!-- Top 10 bar chart -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-body border-0 pt-3 pb-2">
<h6 class="mb-0 fw-semibold"><i class="bi bi-bar-chart-fill me-2 text-primary"></i>Top 10 Customers by Revenue</h6>
</div>
<div class="card-body">
<canvas id="salesByCustomerChart" height="120"></canvas>
</div>
</div>
<!-- Full table -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-body border-0 pt-3 pb-2 d-flex justify-content-between align-items-center">
<h6 class="mb-0 fw-semibold"><i class="bi bi-table me-2 text-primary"></i>All Customers</h6>
<span class="badge bg-secondary">@Model.SalesByCustomer.Count customer@(Model.SalesByCustomer.Count == 1 ? "" : "s")</span>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th class="ps-3">Customer</th>
<th class="text-center">Type</th>
<th class="text-center">Invoices</th>
<th class="text-end">Total Invoiced</th>
<th class="text-end">Total Paid</th>
<th class="text-end">Balance Due</th>
<th class="text-end">Avg Invoice</th>
<th>Last Invoice</th>
<th class="pe-3" style="width:120px;">Share</th>
</tr>
</thead>
<tbody>
@foreach (var c in Model.SalesByCustomer)
{
var share = Model.SalesByCustomerTotalInvoiced > 0
? (double)(c.TotalInvoiced / Model.SalesByCustomerTotalInvoiced * 100)
: 0;
<tr>
<td class="ps-3">
<a asp-controller="Customers" asp-action="Details" asp-route-id="@c.CustomerId"
class="fw-semibold text-decoration-none">@c.CustomerName</a>
</td>
<td class="text-center">
@if (c.IsCommercial)
{
<span class="badge bg-primary-subtle text-primary border border-primary-subtle">Commercial</span>
}
else
{
<span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle">Personal</span>
}
</td>
<td class="text-center"><span class="badge bg-light text-dark border">@c.InvoiceCount</span></td>
<td class="text-end fw-semibold">@c.TotalInvoiced.ToString("C")</td>
<td class="text-end text-success">@c.TotalPaid.ToString("C")</td>
<td class="text-end @(c.BalanceDue > 0 ? "text-danger fw-semibold" : "text-muted")">
@c.BalanceDue.ToString("C")
</td>
<td class="text-end text-muted">@c.AvgInvoiceValue.ToString("C")</td>
<td class="text-muted small">@(c.LastInvoiceDate?.ToString("MMM d, yyyy") ?? "—")</td>
<td class="pe-3">
<div class="d-flex align-items-center gap-2">
<div class="progress flex-grow-1" style="height:6px;">
<div class="progress-bar bg-primary" style="width:@share.ToString("F1")%"></div>
</div>
<small class="text-muted" style="min-width:36px;">@share.ToString("F1")%</small>
</div>
</td>
</tr>
}
</tbody>
<tfoot class="table-light fw-semibold">
<tr>
<td class="ps-3" colspan="3">Total</td>
<td class="text-end">@Model.SalesByCustomerTotalInvoiced.ToString("C")</td>
<td class="text-end text-success">@Model.SalesByCustomerTotalPaid.ToString("C")</td>
<td class="text-end @(Model.SalesByCustomerTotalInvoiced - Model.SalesByCustomerTotalPaid > 0 ? "text-danger" : "text-muted")">
@((Model.SalesByCustomerTotalInvoiced - Model.SalesByCustomerTotalPaid).ToString("C"))
</td>
<td colspan="3"></td>
</tr>
</tfoot>
</table>
</div>
</div>
}
else
{
<div class="text-center text-muted py-5">
<i class="bi bi-people fs-1 d-block mb-3 opacity-25"></i>
<p>No invoice data available for the selected period.</p>
</div>
}
</div>
<!-- ─── CUSTOMER RETENTION TAB ─────────────────────────────────────────── -->
<div class="tab-pane fade" id="customer-retention-tab">
@{
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");
}
<div class="row g-3 mb-4">
<div class="col-6 col-md-3 col-lg">
<div class="card border-0 shadow-sm text-center h-100">
<div class="card-body py-3">
<div class="fs-2 fw-bold text-success">@retActive</div>
<div class="small text-muted">Active</div>
<div class="small text-muted">&lt;30 days</div>
</div>
</div>
</div>
<div class="col-6 col-md-3 col-lg">
<div class="card border-0 shadow-sm text-center h-100">
<div class="card-body py-3">
<div class="fs-2 fw-bold text-warning">@retAtRisk</div>
<div class="small text-muted">At Risk</div>
<div class="small text-muted">3060 days</div>
</div>
</div>
</div>
<div class="col-6 col-md-3 col-lg">
<div class="card border-0 shadow-sm text-center h-100">
<div class="card-body py-3">
<div class="fs-2 fw-bold text-orange" style="color:#f97316;">@retLapsing</div>
<div class="small text-muted">Lapsing</div>
<div class="small text-muted">6090 days</div>
</div>
</div>
</div>
<div class="col-6 col-md-3 col-lg">
<div class="card border-0 shadow-sm text-center h-100">
<div class="card-body py-3">
<div class="fs-2 fw-bold text-danger">@retChurned</div>
<div class="small text-muted">Churned</div>
<div class="small text-muted">&gt;90 days</div>
</div>
</div>
</div>
<div class="col-6 col-md-3 col-lg">
<div class="card border-0 shadow-sm text-center h-100">
<div class="card-body py-3">
<div class="fs-2 fw-bold text-secondary">@retNever</div>
<div class="small text-muted">Never Ordered</div>
</div>
</div>
</div>
</div>
@if (Model.CustomerRetention.Any())
{
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-body border-0 pt-3 pb-2">
<h6 class="mb-0 fw-semibold"><i class="bi bi-pie-chart-fill me-2 text-primary"></i>Retention Breakdown</h6>
</div>
<div class="card-body d-flex align-items-center justify-content-center">
<canvas id="retentionDonutChart" height="220"></canvas>
</div>
</div>
</div>
<div class="col-md-8">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-body border-0 pt-3 pb-2 d-flex justify-content-between align-items-center">
<h6 class="mb-0 fw-semibold"><i class="bi bi-table me-2 text-primary"></i>Customer Retention Detail</h6>
<span class="badge bg-secondary">@Model.CustomerRetention.Count customers</span>
</div>
<div class="table-responsive" style="max-height:320px;overflow-y:auto;">
<table class="table table-hover table-sm mb-0 align-middle">
<thead class="table-light sticky-top">
<tr>
<th class="ps-3">Customer</th>
<th class="text-center">Status</th>
<th class="text-center">Last Job</th>
<th class="text-center">Days Since</th>
<th class="text-end">Total Jobs</th>
<th class="text-end pe-3">Lifetime Revenue</th>
</tr>
</thead>
<tbody>
@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"
};
<tr>
<td class="ps-3">
<a asp-controller="Customers" asp-action="Details" asp-route-id="@r.CustomerId"
class="fw-semibold text-decoration-none">@r.CustomerName</a>
@if (!string.IsNullOrEmpty(r.Email))
{
<div class="small text-muted">@r.Email</div>
}
</td>
<td class="text-center">
<span class="badge @badgeClass">@r.RetentionStatus</span>
</td>
<td class="text-center small">
@(r.LastJobDate.HasValue ? r.LastJobDate.Value.ToString("MMM d, yyyy") : "—")
</td>
<td class="text-center small">
@(r.DaysSinceLastJob >= 0 ? r.DaysSinceLastJob + "d" : "—")
</td>
<td class="text-end">@r.TotalJobs</td>
<td class="text-end pe-3 fw-semibold">@r.LifetimeRevenue.ToString("C0")</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
}
else
{
<div class="text-center text-muted py-5">
<i class="bi bi-arrow-repeat fs-1 d-block mb-3 opacity-25"></i>
<p>No customer data available.</p>
</div>
}
</div>
<!-- ─── JOB CYCLE TIME TAB ──────────────────────────────────────────────── -->
<div class="tab-pane fade" id="job-cycle-time-tab">
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center">
<p class="text-muted mb-1 small">Overall Avg Cycle Time</p>
<h3 class="mb-0 fw-bold">@Math.Round(Model.OverallAvgCycleDays, 1)d</h3>
<small class="text-muted">From creation to completion</small>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center">
<p class="text-muted mb-1 small">Statuses Tracked</p>
<h3 class="mb-0 fw-bold">@Model.JobCycleTime.Count</h3>
<small class="text-muted">With time data</small>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center">
<p class="text-muted mb-1 small">Longest Avg Stage</p>
@if (Model.JobCycleTime.Any())
{
var longest = Model.JobCycleTime.OrderByDescending(x => x.AvgDays).First();
<h3 class="mb-0 fw-bold">@longest.AvgDays d</h3>
<small class="text-muted">@longest.StatusName</small>
}
else
{
<h3 class="mb-0 fw-bold text-muted">—</h3>
}
</div>
</div>
</div>
</div>
@if (Model.JobCycleTime.Any())
{
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-body border-0 pt-3 pb-2">
<h6 class="mb-0 fw-semibold"><i class="bi bi-bar-chart-fill me-2 text-primary"></i>Avg Days per Stage</h6>
</div>
<div class="card-body">
<canvas id="jobCycleTimeChart" height="100"></canvas>
</div>
</div>
<div class="card border-0 shadow-sm">
<div class="card-header bg-body border-0 pt-3 pb-2">
<h6 class="mb-0 fw-semibold"><i class="bi bi-table me-2 text-primary"></i>Stage Breakdown</h6>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th class="ps-3">Stage</th>
<th class="text-end">Avg Days</th>
<th class="text-end">Min Days</th>
<th class="text-end">Max Days</th>
<th class="text-end pe-3">Jobs Sampled</th>
</tr>
</thead>
<tbody>
@foreach (var s in Model.JobCycleTime)
{
<tr>
<td class="ps-3 fw-semibold">@s.StatusName</td>
<td class="text-end">@s.AvgDays.ToString("N1")</td>
<td class="text-end text-muted">@s.MinDays.ToString("N1")</td>
<td class="text-end text-muted">@s.MaxDays.ToString("N1")</td>
<td class="text-end pe-3">@s.JobCount</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
else
{
<div class="text-center text-muted py-5">
<i class="bi bi-stopwatch fs-1 d-block mb-3 opacity-25"></i>
<p>Not enough job history data to calculate cycle times yet.</p>
</div>
}
</div>
<!-- ─── JOB STATUS AGING TAB ────────────────────────────────────────────── -->
<div class="tab-pane fade" id="job-status-aging-tab">
@{
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);
}
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center">
<p class="text-muted mb-1 small">Active Jobs</p>
<h3 class="mb-0 fw-bold">@Model.JobStatusAging.Count</h3>
<small class="text-muted">Currently in progress</small>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center">
<p class="text-muted mb-1 small">Overdue</p>
<h3 class="mb-0 fw-bold @(agingOverdue > 0 ? "text-danger" : "text-success")">@agingOverdue</h3>
<small class="text-muted">Past due date</small>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center">
<p class="text-muted mb-1 small">Avg Days in Status</p>
<h3 class="mb-0 fw-bold">@agingAvgDays d</h3>
<small class="text-muted">@agingStale stale (&ge;7 days)</small>
</div>
</div>
</div>
</div>
@if (Model.JobStatusAging.Any())
{
<div class="card border-0 shadow-sm">
<div class="card-header bg-body border-0 pt-3 pb-2 d-flex justify-content-between align-items-center">
<h6 class="mb-0 fw-semibold"><i class="bi bi-hourglass-split me-2 text-primary"></i>Active Jobs by Age</h6>
<span class="badge bg-secondary">@Model.JobStatusAging.Count jobs</span>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th class="ps-3">Job #</th>
<th>Customer</th>
<th>Current Status</th>
<th>Priority</th>
<th class="text-end">Days in Status</th>
<th>Due Date</th>
</tr>
</thead>
<tbody>
@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"
};
<tr>
<td class="ps-3">
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@j.JobId"
class="fw-semibold text-decoration-none">@j.JobNumber</a>
</td>
<td>@j.CustomerName</td>
<td><span class="badge bg-light text-dark border">@j.StatusName</span></td>
<td><span class="badge @priorityBadge">@j.PriorityName</span></td>
<td class="text-end">
<span class="badge @agingBadge">@j.DaysInCurrentStatus d</span>
</td>
<td>
@if (j.DueDate.HasValue)
{
<span class="@(j.IsOverdue ? "text-danger fw-semibold" : "text-muted")">
@j.DueDate.Value.ToString("MMM d, yyyy")
@if (j.IsOverdue) { <i class="bi bi-exclamation-triangle-fill ms-1"></i> }
</span>
}
else
{
<span class="text-muted">—</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
else
{
<div class="text-center text-muted py-5">
<i class="bi bi-hourglass fs-1 d-block mb-3 opacity-25"></i>
<p>No active jobs to display.</p>
</div>
}
</div>
@if (allowAccounting)
{
<!-- ─── INVOICE AGING DETAIL TAB ────────────────────────────────────────── -->
<div class="tab-pane fade" id="invoice-aging-detail-tab">
@{
var iadCurrent = Model.InvoiceAgingDetail.Where(i => i.AgingBucket == "Current").ToList();
var iad1to30 = Model.InvoiceAgingDetail.Where(i => i.AgingBucket == "130 Days").ToList();
var iad31to60 = Model.InvoiceAgingDetail.Where(i => i.AgingBucket == "3160 Days").ToList();
var iad61to90 = Model.InvoiceAgingDetail.Where(i => i.AgingBucket == "6190 Days").ToList();
var iad90plus = Model.InvoiceAgingDetail.Where(i => i.AgingBucket == "90+ Days").ToList();
}
<div class="row g-3 mb-4">
<div class="col">
<div class="card border-0 shadow-sm text-center h-100">
<div class="card-body py-3">
<div class="fs-5 fw-bold text-success">@iadCurrent.Sum(i => i.BalanceDue).ToString("C0")</div>
<div class="small text-muted">Current</div>
<div class="small text-muted">@iadCurrent.Count invoices</div>
</div>
</div>
</div>
<div class="col">
<div class="card border-0 shadow-sm text-center h-100">
<div class="card-body py-3">
<div class="fs-5 fw-bold text-warning">@iad1to30.Sum(i => i.BalanceDue).ToString("C0")</div>
<div class="small text-muted">130 Days</div>
<div class="small text-muted">@iad1to30.Count invoices</div>
</div>
</div>
</div>
<div class="col">
<div class="card border-0 shadow-sm text-center h-100">
<div class="card-body py-3">
<div class="fs-5 fw-bold" style="color:#f97316;">@iad31to60.Sum(i => i.BalanceDue).ToString("C0")</div>
<div class="small text-muted">3160 Days</div>
<div class="small text-muted">@iad31to60.Count invoices</div>
</div>
</div>
</div>
<div class="col">
<div class="card border-0 shadow-sm text-center h-100">
<div class="card-body py-3">
<div class="fs-5 fw-bold text-danger">@iad61to90.Sum(i => i.BalanceDue).ToString("C0")</div>
<div class="small text-muted">6190 Days</div>
<div class="small text-muted">@iad61to90.Count invoices</div>
</div>
</div>
</div>
<div class="col">
<div class="card border-0 shadow-sm text-center h-100">
<div class="card-body py-3">
<div class="fs-5 fw-bold text-danger fw-bolder">@iad90plus.Sum(i => i.BalanceDue).ToString("C0")</div>
<div class="small text-muted">90+ Days</div>
<div class="small text-muted">@iad90plus.Count invoices</div>
</div>
</div>
</div>
</div>
@if (Model.InvoiceAgingDetail.Any())
{
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-body border-0 pt-3 pb-2">
<h6 class="mb-0 fw-semibold"><i class="bi bi-bar-chart-fill me-2 text-primary"></i>Outstanding Balance by Aging Bucket</h6>
</div>
<div class="card-body">
<canvas id="invoiceAgingChart" height="80"></canvas>
</div>
</div>
<div class="card border-0 shadow-sm">
<div class="card-header bg-body border-0 pt-3 pb-2 d-flex justify-content-between align-items-center">
<h6 class="mb-0 fw-semibold"><i class="bi bi-table me-2 text-primary"></i>Outstanding Invoice Detail</h6>
<span class="badge bg-secondary">@Model.InvoiceAgingDetail.Count invoices</span>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th class="ps-3">Invoice #</th>
<th>Customer</th>
<th>Invoice Date</th>
<th>Due Date</th>
<th class="text-end">Total</th>
<th class="text-end">Paid</th>
<th class="text-end">Balance Due</th>
<th class="text-center">Days Overdue</th>
<th class="pe-3">Bucket</th>
</tr>
</thead>
<tbody>
@foreach (var inv in Model.InvoiceAgingDetail)
{
var bucketBadge = inv.AgingBucket switch {
"Current" => "bg-success",
"130 Days" => "bg-warning text-dark",
"3160 Days" => "badge-orange",
"6190 Days" => "bg-danger",
_ => "bg-danger"
};
<tr>
<td class="ps-3">
<a asp-controller="Invoices" asp-action="Details" asp-route-id="@inv.InvoiceId"
class="fw-semibold text-decoration-none">@inv.InvoiceNumber</a>
</td>
<td>
<div>@inv.CustomerName</div>
@if (!string.IsNullOrEmpty(inv.CustomerEmail))
{
<div class="small text-muted">@inv.CustomerEmail</div>
}
</td>
<td class="small">@inv.InvoiceDate.ToString("MMM d, yyyy")</td>
<td class="small">@(inv.DueDate.HasValue ? inv.DueDate.Value.ToString("MMM d, yyyy") : "—")</td>
<td class="text-end">@inv.Total.ToString("C")</td>
<td class="text-end text-success">@inv.AmountPaid.ToString("C")</td>
<td class="text-end fw-semibold @(inv.BalanceDue > 0 ? "text-danger" : "")">@inv.BalanceDue.ToString("C")</td>
<td class="text-center">
@if (inv.DaysOverdue > 0)
{
<span class="badge bg-danger">@inv.DaysOverdue d</span>
}
else
{
<span class="text-muted small">current</span>
}
</td>
<td class="pe-3">
<span class="badge @bucketBadge" style="@(inv.AgingBucket == "3160 Days" ? "background:#f97316;" : "")">@inv.AgingBucket</span>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
else
{
<div class="text-center text-muted py-5">
<i class="bi bi-calendar-x fs-1 d-block mb-3 opacity-25"></i>
<p>No outstanding invoices.</p>
</div>
}
</div>
} <!-- /allowAccounting: AR Aging tab -->
<!-- ─── POWDER CONSUMPTION VS PURCHASE TAB ──────────────────────────────── -->
<div class="tab-pane fade" id="powder-consumption-tab">
@{
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;
}
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center">
<p class="text-muted mb-1 small">Total Purchased</p>
<h3 class="mb-0 fw-bold">@pcTotalPurchased.ToString("N1") lbs</h3>
<small class="text-muted">All time</small>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center">
<p class="text-muted mb-1 small">Total Consumed</p>
<h3 class="mb-0 fw-bold">@pcTotalConsumed.ToString("N1") lbs</h3>
<small class="text-muted">Job usage + waste</small>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center">
<p class="text-muted mb-1 small">Utilization Rate</p>
<h3 class="mb-0 fw-bold @(pcEfficiency >= 80 ? "text-success" : pcEfficiency >= 60 ? "text-warning" : "text-danger")">@pcEfficiency%</h3>
<small class="text-muted">Consumed / Purchased</small>
</div>
</div>
</div>
</div>
@if (Model.PowderConsumption.Any())
{
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-body border-0 pt-3 pb-2">
<h6 class="mb-0 fw-semibold"><i class="bi bi-bar-chart-fill me-2 text-primary"></i>Top 10 Powders — Purchased vs Consumed (lbs)</h6>
</div>
<div class="card-body">
<canvas id="powderConsumptionChart" height="100"></canvas>
</div>
</div>
<div class="card border-0 shadow-sm">
<div class="card-header bg-body border-0 pt-3 pb-2 d-flex justify-content-between align-items-center">
<h6 class="mb-0 fw-semibold"><i class="bi bi-table me-2 text-primary"></i>Powder Detail</h6>
<span class="badge bg-secondary">@Model.PowderConsumption.Count items</span>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th class="ps-3">Item / Color</th>
<th>Manufacturer</th>
<th class="text-end">Purchased (lbs)</th>
<th class="text-end">Consumed (lbs)</th>
<th class="text-end">Utilization</th>
<th class="text-end pe-3">Jobs Used</th>
</tr>
</thead>
<tbody>
@foreach (var p in Model.PowderConsumption)
{
var util = p.TotalPurchasedLbs > 0
? Math.Round(p.TotalConsumedLbs / p.TotalPurchasedLbs * 100, 1) : 0m;
<tr>
<td class="ps-3">
<div class="fw-semibold">@(p.ColorName ?? p.ItemName)</div>
@if (!string.IsNullOrEmpty(p.ColorCode))
{
<div class="small text-muted">@p.ColorCode @(!string.IsNullOrEmpty(p.SKU) ? $"· {p.SKU}" : "")</div>
}
</td>
<td class="small text-muted">@(p.Manufacturer ?? "—")</td>
<td class="text-end">@p.TotalPurchasedLbs.ToString("N1")</td>
<td class="text-end">@p.TotalConsumedLbs.ToString("N1")</td>
<td class="text-end">
<span class="badge @(util >= 80 ? "bg-success" : util >= 60 ? "bg-warning text-dark" : "bg-danger")">
@util%
</span>
</td>
<td class="text-end pe-3">@p.UsageJobCount</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
else
{
<div class="text-center text-muted py-5">
<i class="bi bi-bar-chart-steps fs-1 d-block mb-3 opacity-25"></i>
<p>No inventory transaction data available.</p>
</div>
}
</div>
<!-- ─── INVENTORY TURNOVER TAB ───────────────────────────────────────────── -->
<div class="tab-pane fade" id="inventory-turnover-tab">
@{
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;
}
<div class="row g-3 mb-4">
<div class="col">
<div class="card border-0 shadow-sm text-center h-100">
<div class="card-body py-3">
<div class="fs-2 fw-bold text-danger">@itCritical</div>
<div class="small text-muted">Critical</div>
<div class="small text-muted">&lt;7 days</div>
</div>
</div>
</div>
<div class="col">
<div class="card border-0 shadow-sm text-center h-100">
<div class="card-body py-3">
<div class="fs-2 fw-bold text-warning">@itLow</div>
<div class="small text-muted">Low</div>
<div class="small text-muted">730 days</div>
</div>
</div>
</div>
<div class="col">
<div class="card border-0 shadow-sm text-center h-100">
<div class="card-body py-3">
<div class="fs-2 fw-bold" style="color:#f97316;">@itOverstocked</div>
<div class="small text-muted">Overstocked</div>
<div class="small text-muted">&gt;90 days</div>
</div>
</div>
</div>
<div class="col">
<div class="card border-0 shadow-sm text-center h-100">
<div class="card-body py-3">
<div class="fs-2 fw-bold text-secondary">@itNoUsage</div>
<div class="small text-muted">No Usage</div>
</div>
</div>
</div>
<div class="col">
<div class="card border-0 shadow-sm text-center h-100">
<div class="card-body py-3">
<div class="fs-2 fw-bold text-primary">@itAvgTurnover.ToString("N2")x</div>
<div class="small text-muted">Avg Turnover</div>
<div class="small text-muted">Consumed / On Hand</div>
</div>
</div>
</div>
</div>
@if (Model.InventoryTurnover.Any())
{
<div class="card border-0 shadow-sm">
<div class="card-header bg-body border-0 pt-3 pb-2 d-flex justify-content-between align-items-center">
<h6 class="mb-0 fw-semibold"><i class="bi bi-table me-2 text-primary"></i>Inventory Turnover Detail</h6>
<span class="badge bg-secondary">@Model.InventoryTurnover.Count items</span>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th class="ps-3">Item / Color</th>
<th class="text-end">On Hand (lbs)</th>
<th class="text-end">Daily Use (lbs)</th>
<th class="text-end">Days to Stockout</th>
<th class="text-end">Turnover Rate</th>
<th class="text-end pe-3">Total Consumed</th>
<th class="text-center">Status</th>
</tr>
</thead>
<tbody>
@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"
};
<tr>
<td class="ps-3">
<div class="fw-semibold">@(it.ColorName ?? it.ItemName)</div>
@if (!string.IsNullOrEmpty(it.SKU))
{
<div class="small text-muted">@it.SKU</div>
}
</td>
<td class="text-end">@it.CurrentStockLbs.ToString("N1")</td>
<td class="text-end small">@it.DailyConsumptionLbs.ToString("N3")</td>
<td class="text-end">
@(it.DaysToStockout >= 9999 ? "∞" : it.DaysToStockout.ToString("N0") + " d")
</td>
<td class="text-end">@it.TurnoverRate.ToString("N2")x</td>
<td class="text-end pe-3">@it.TotalConsumedLbs.ToString("N1") lbs</td>
<td class="text-center">
<span class="badge @statusBadge">@it.StockStatus</span>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
else
{
<div class="text-center text-muted py-5">
<i class="bi bi-arrow-clockwise fs-1 d-block mb-3 opacity-25"></i>
<p>No inventory data available.</p>
</div>
}
</div>
</div>
@section Scripts {
<script src="~/lib/chartjs/chart.umd.min.js"></script>
<script>
const isDark = document.documentElement.getAttribute('data-bs-theme') === 'dark';
const gridColor = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)';
const labelColor = isDark ? '#adb5bd' : '#6c757d';
const chartBgColor = isDark ? '#212529' : '#ffffff';
// Chart.js default config
Chart.defaults.color = labelColor;
Chart.defaults.borderColor = gridColor;
// OVERVIEW TAB CHARTS
// Overview Revenue Chart (Line + Bar)
new Chart(document.getElementById('overviewRevenueChart'), {
type: 'line',
data: {
labels: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.MonthLabels)),
datasets: [{
label: 'Revenue',
data: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.MonthlyRevenue)),
borderColor: '#4f46e5',
backgroundColor: 'rgba(79,70,229,0.1)',
fill: true,
tension: 0.4,
yAxisID: 'y'
}]
},
options: {
responsive: true,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: chartBgColor,
titleColor: labelColor,
bodyColor: labelColor,
borderColor: gridColor,
borderWidth: 1,
callbacks: {
label: ctx => '$' + ctx.parsed.y.toLocaleString('en-US', { minimumFractionDigits: 0 })
}
}
},
scales: {
y: {
type: 'linear',
position: 'left',
beginAtZero: true,
grid: { color: gridColor },
ticks: {
color: labelColor,
callback: v => '$' + (v / 1000).toFixed(0) + 'k'
}
},
x: { grid: { display: false }, ticks: { color: labelColor } }
}
}
});
// Job Status Donut
@if (Model.JobsByStatus.Any())
{
<text>
new Chart(document.getElementById('jobStatusDonut'), {
type: 'doughnut',
data: {
labels: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.JobsByStatus.Keys)),
datasets: [{
data: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.JobsByStatus.Values)),
backgroundColor: ['#4f46e5','#06b6d4','#f59e0b','#10b981','#ef4444','#8b5cf6','#ec4899','#14b8a6'],
borderWidth: 2,
borderColor: chartBgColor
}]
},
options: {
responsive: true,
plugins: {
legend: { position: 'bottom', labels: { color: labelColor, boxWidth: 12, padding: 8 } }
}
}
});
</text>
}
// REVENUE TAB CHARTS
// Revenue & Job Count Dual Axis
new Chart(document.getElementById('revenueJobTrendChart'), {
type: 'bar',
data: {
labels: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.MonthLabels)),
datasets: [{
label: 'Revenue',
data: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.MonthlyRevenue)),
backgroundColor: 'rgba(79,70,229,0.7)',
borderColor: '#4f46e5',
borderWidth: 1,
yAxisID: 'y',
order: 2
}, {
label: 'Job Count',
data: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.MonthlyJobCount)),
type: 'line',
borderColor: '#10b981',
backgroundColor: 'rgba(16,185,129,0.1)',
fill: false,
yAxisID: 'y1',
order: 1,
tension: 0.4
}]
},
options: {
responsive: true,
interaction: { mode: 'index', intersect: false },
plugins: {
tooltip: {
backgroundColor: chartBgColor,
titleColor: labelColor,
bodyColor: labelColor,
borderColor: gridColor,
borderWidth: 1
}
},
scales: {
y: {
type: 'linear',
position: 'left',
beginAtZero: true,
grid: { color: gridColor },
ticks: { color: labelColor, callback: v => '$' + (v / 1000).toFixed(0) + 'k' },
title: { display: true, text: 'Revenue', color: labelColor }
},
y1: {
type: 'linear',
position: 'right',
beginAtZero: true,
grid: { display: false },
ticks: { color: labelColor },
title: { display: true, text: 'Jobs', color: labelColor }
},
x: { grid: { display: false }, ticks: { color: labelColor } }
}
}
});
// Average Order Value Trend
new Chart(document.getElementById('avgOrderValueChart'), {
type: 'line',
data: {
labels: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.MonthLabels)),
datasets: [{
label: 'Avg Order Value',
data: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.AverageOrderValueTrend)),
borderColor: '#10b981',
backgroundColor: 'rgba(16,185,129,0.1)',
fill: true,
tension: 0.4
}]
},
options: {
responsive: true,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: chartBgColor,
titleColor: labelColor,
bodyColor: labelColor,
borderColor: gridColor,
borderWidth: 1,
callbacks: { label: ctx => '$' + ctx.parsed.y.toLocaleString() }
}
},
scales: {
y: {
beginAtZero: true,
grid: { color: gridColor },
ticks: { color: labelColor, callback: v => '$' + (v / 1000).toFixed(0) + 'k' }
},
x: { grid: { display: false }, ticks: { color: labelColor } }
}
}
});
// Revenue by Customer Type
@if (Model.RevenueByCustomerType.Any())
{
<text>
new Chart(document.getElementById('revenueByCustomerTypeChart'), {
type: 'pie',
data: {
labels: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.RevenueByCustomerType.Keys)),
datasets: [{
data: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.RevenueByCustomerType.Values)),
backgroundColor: ['#4f46e5', '#10b981'],
borderWidth: 2,
borderColor: chartBgColor
}]
},
options: {
responsive: true,
plugins: {
legend: { position: 'bottom', labels: { color: labelColor } },
tooltip: {
backgroundColor: chartBgColor,
titleColor: labelColor,
bodyColor: labelColor,
borderColor: gridColor,
borderWidth: 1,
callbacks: { label: ctx => ctx.label + ': $' + ctx.parsed.toLocaleString() }
}
}
}
});
</text>
}
// Revenue by Priority
@if (Model.RevenueByPriority.Any())
{
<text>
new Chart(document.getElementById('revenueByPriorityChart'), {
type: 'bar',
data: {
labels: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.RevenueByPriority.Keys)),
datasets: [{
data: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.RevenueByPriority.Values)),
backgroundColor: ['#6b7280','#3b82f6','#f59e0b','#ef4444','#dc2626'],
borderWidth: 1,
borderColor: chartBgColor
}]
},
options: {
responsive: true,
indexAxis: 'y',
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: chartBgColor,
titleColor: labelColor,
bodyColor: labelColor,
borderColor: gridColor,
borderWidth: 1,
callbacks: { label: ctx => '$' + ctx.parsed.x.toLocaleString() }
}
},
scales: {
x: {
beginAtZero: true,
grid: { color: gridColor },
ticks: { color: labelColor, callback: v => '$' + (v / 1000).toFixed(0) + 'k' }
},
y: { grid: { display: false }, ticks: { color: labelColor } }
}
}
});
</text>
}
// OPERATIONS TAB CHARTS
// Jobs by Priority
@if (Model.ActiveJobsByPriority.Any())
{
<text>
new Chart(document.getElementById('jobsByPriorityChart'), {
type: 'doughnut',
data: {
labels: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.ActiveJobsByPriority.Keys)),
datasets: [{
data: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.ActiveJobsByPriority.Values)),
backgroundColor: ['#6b7280','#3b82f6','#f59e0b','#ef4444','#dc2626'],
borderWidth: 2,
borderColor: chartBgColor
}]
},
options: {
responsive: true,
plugins: {
legend: { position: 'bottom', labels: { color: labelColor, boxWidth: 12 } }
}
}
});
</text>
}
// Appointments by Type
@if (Model.AppointmentsByType.Any())
{
<text>
new Chart(document.getElementById('appointmentsByTypeChart'), {
type: 'pie',
data: {
labels: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.AppointmentsByType.Keys)),
datasets: [{
data: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.AppointmentsByType.Values)),
backgroundColor: ['#8b5cf6','#10b981','#06b6d4','#f59e0b'],
borderWidth: 2,
borderColor: chartBgColor
}]
},
options: {
responsive: true,
plugins: {
legend: { position: 'bottom', labels: { color: labelColor, boxWidth: 12 } }
}
}
});
</text>
}
// Appointments by Day of Week
@if (Model.AppointmentsByDayOfWeek.Any())
{
<text>
new Chart(document.getElementById('appointmentsByDayChart'), {
type: 'bar',
data: {
labels: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.AppointmentsByDayOfWeek.Keys)),
datasets: [{
data: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.AppointmentsByDayOfWeek.Values)),
backgroundColor: 'rgba(139,92,246,0.7)',
borderColor: '#8b5cf6',
borderWidth: 1
}]
},
options: {
responsive: true,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: chartBgColor,
titleColor: labelColor,
bodyColor: labelColor,
borderColor: gridColor,
borderWidth: 1
}
},
scales: {
y: {
beginAtZero: true,
grid: { color: gridColor },
ticks: { color: labelColor, stepSize: 1 }
},
x: { grid: { display: false }, ticks: { color: labelColor } }
}
}
});
</text>
}
// CUSTOMERS TAB CHARTS
// New Customers per Month
new Chart(document.getElementById('newCustomersChart'), {
type: 'bar',
data: {
labels: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.MonthLabels)),
datasets: [{
label: 'New Customers',
data: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.NewCustomersPerMonth)),
backgroundColor: 'rgba(16,185,129,0.7)',
borderColor: '#10b981',
borderWidth: 1
}]
},
options: {
responsive: true,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: chartBgColor,
titleColor: labelColor,
bodyColor: labelColor,
borderColor: gridColor,
borderWidth: 1
}
},
scales: {
y: {
beginAtZero: true,
grid: { color: gridColor },
ticks: { color: labelColor, stepSize: 1 }
},
x: { grid: { display: false }, ticks: { color: labelColor } }
}
}
});
// FINANCIAL TAB CHARTS
new Chart(document.getElementById('financialMonthlyChart'), {
type: 'bar',
data: {
labels: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.MonthLabels)),
datasets: [{
label: 'Invoiced',
data: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.MonthlyInvoiced)),
backgroundColor: 'rgba(79,70,229,0.7)',
borderColor: '#4f46e5',
borderWidth: 1,
order: 2
}, {
label: 'Collected',
data: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.MonthlyCollected)),
type: 'line',
borderColor: '#10b981',
backgroundColor: 'rgba(16,185,129,0.1)',
fill: false,
tension: 0.4,
order: 1
}]
},
options: {
responsive: true,
interaction: { mode: 'index', intersect: false },
plugins: {
tooltip: {
backgroundColor: chartBgColor,
titleColor: labelColor,
bodyColor: labelColor,
borderColor: gridColor,
borderWidth: 1,
callbacks: { label: ctx => ctx.dataset.label + ': $' + ctx.parsed.y.toLocaleString('en-US', { minimumFractionDigits: 0 }) }
}
},
scales: {
y: {
beginAtZero: true,
grid: { color: gridColor },
ticks: { color: labelColor, callback: v => '$' + (v / 1000).toFixed(0) + 'k' }
},
x: { grid: { display: false }, ticks: { color: labelColor } }
}
}
});
// EXPENSES TAB CHARTS
// P&L Chart
const plLabels = @Json.Serialize(Model.MonthLabels);
const plRevenue = @Json.Serialize(Model.PlRevenue);
const plExpenses = @Json.Serialize(Model.PlExpenses);
const plNetIncome = @Json.Serialize(Model.PlNetIncome);
new Chart(document.getElementById('plChart'), {
type: 'bar',
data: {
labels: plLabels,
datasets: [
{ label: 'Revenue (Collected)', data: plRevenue, backgroundColor: 'rgba(25,135,84,0.7)', borderColor: 'rgb(25,135,84)', borderWidth: 1 },
{ label: 'Expenses', data: plExpenses, backgroundColor: 'rgba(220,53,69,0.7)', borderColor: 'rgb(220,53,69)', borderWidth: 1 },
{ type: 'line', label: 'Net Income', data: plNetIncome, borderColor: 'rgb(13,110,253)', backgroundColor: 'transparent', borderWidth: 2, pointRadius: 4, tension: 0.3 }
]
},
options: {
responsive: true,
plugins: {
legend: { labels: { color: labelColor } },
tooltip: { callbacks: { label: ctx => ctx.dataset.label + ': $' + ctx.parsed.y.toLocaleString('en-US', { minimumFractionDigits: 0 }) } }
},
scales: {
y: { beginAtZero: true, grid: { color: gridColor }, ticks: { color: labelColor, callback: v => '$' + (v/1000).toFixed(0) + 'k' } },
x: { grid: { display: false }, ticks: { color: labelColor } }
}
}
});
// Expenses by Account doughnut
const expAcctLabels = @Json.Serialize(Model.ExpensesByAccount.Select(e => e.AccountName).ToList());
const expAcctData = @Json.Serialize(Model.ExpensesByAccount.Select(e => e.Amount).ToList());
if (document.getElementById('expByAccountChart') && expAcctData.length > 0) {
new Chart(document.getElementById('expByAccountChart'), {
type: 'doughnut',
data: {
labels: expAcctLabels,
datasets: [{ data: expAcctData, backgroundColor: ['#0d6efd','#6f42c1','#d63384','#fd7e14','#ffc107','#198754','#20c997','#0dcaf0','#6c757d','#dc3545'] }]
},
options: {
responsive: true,
plugins: {
legend: { position: 'right', labels: { color: labelColor, boxWidth: 12 } },
tooltip: { callbacks: { label: ctx => ctx.label + ': $' + ctx.parsed.toLocaleString('en-US', { minimumFractionDigits: 2 }) } }
}
}
});
}
// Top Colors horizontal bar chart
const topColorLabels = @Json.Serialize(Model.TopColorsByUsage.Select(c => c.DisplayLabel).ToList());
const topColorData = @Json.Serialize(Model.TopColorsByUsage.Select(c => c.TotalLbsUsed).ToList());
if (document.getElementById('topColorsChart') && topColorLabels.length > 0) {
const colorPalette = ['#0d6efd','#6f42c1','#d63384','#fd7e14','#ffc107','#198754','#20c997','#0dcaf0','#6c757d','#dc3545',
'#4361ee','#7209b7','#f72585','#b5179e','#560bad'];
new Chart(document.getElementById('topColorsChart'), {
type: 'bar',
data: {
labels: topColorLabels,
datasets: [{
label: 'lbs used',
data: topColorData,
backgroundColor: topColorLabels.map((_, i) => colorPalette[i % colorPalette.length]),
borderRadius: 4
}]
},
options: {
indexAxis: 'y',
responsive: true,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: chartBgColor, titleColor: labelColor, bodyColor: labelColor,
borderColor: gridColor, borderWidth: 1,
callbacks: { label: ctx => ctx.parsed.x.toFixed(1) + ' lbs' }
}
},
scales: {
x: { beginAtZero: true, grid: { color: gridColor }, ticks: { color: labelColor, callback: v => v + ' lbs' } },
y: { grid: { display: false }, ticks: { color: labelColor } }
}
}
});
}
// Monthly Powder Consumption Trend
const powderMonthLabels = @Json.Serialize(Model.MonthLabels);
const powderLbsData = @Json.Serialize(Model.MonthlyPowderUsageLbs);
const powderCostData = @Json.Serialize(Model.MonthlyPowderUsageCost);
if (document.getElementById('powderTrendChart')) {
new Chart(document.getElementById('powderTrendChart'), {
type: 'bar',
data: {
labels: powderMonthLabels,
datasets: [
{
label: 'lbs used',
data: powderLbsData,
backgroundColor: 'rgba(13,110,253,0.7)',
borderRadius: 4,
yAxisID: 'yLbs'
},
{
type: 'line',
label: 'cost',
data: powderCostData,
borderColor: '#fd7e14',
backgroundColor: 'rgba(253,126,20,0.1)',
fill: false,
tension: 0.4,
yAxisID: 'yCost'
}
]
},
options: {
responsive: true,
interaction: { mode: 'index', intersect: false },
plugins: {
tooltip: {
backgroundColor: chartBgColor, titleColor: labelColor, bodyColor: labelColor,
borderColor: gridColor, borderWidth: 1,
callbacks: {
label: ctx => ctx.dataset.label === 'lbs used'
? ctx.parsed.y.toFixed(1) + ' lbs'
: '$' + ctx.parsed.y.toLocaleString('en-US', { minimumFractionDigits: 2 })
}
},
legend: { labels: { color: labelColor } }
},
scales: {
yLbs: { beginAtZero: true, position: 'left', grid: { color: gridColor }, ticks: { color: labelColor, callback: v => v + ' lbs' } },
yCost: { beginAtZero: true, position: 'right', grid: { display: false }, ticks: { color: labelColor, callback: v => '$' + (v/1000).toFixed(1) + 'k' } },
x: { grid: { display: false }, ticks: { color: labelColor } }
}
}
});
}
// SALES BY CUSTOMER CHART
if (document.getElementById('salesByCustomerChart')) {
const sbcLabels = @Json.Serialize(Model.SalesByCustomer.Take(10).Select(c => c.CustomerName));
const sbcData = @Json.Serialize(Model.SalesByCustomer.Take(10).Select(c => c.TotalInvoiced));
const sbcPaid = @Json.Serialize(Model.SalesByCustomer.Take(10).Select(c => c.TotalPaid));
new Chart(document.getElementById('salesByCustomerChart'), {
type: 'bar',
data: {
labels: sbcLabels,
datasets: [
{
label: 'Invoiced',
data: sbcData,
backgroundColor: 'rgba(79,70,229,0.7)',
borderColor: '#4f46e5',
borderWidth: 1,
borderRadius: 4
},
{
label: 'Collected',
data: sbcPaid,
backgroundColor: 'rgba(16,185,129,0.7)',
borderColor: '#10b981',
borderWidth: 1,
borderRadius: 4
}
]
},
options: {
responsive: true,
indexAxis: 'y',
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { position: 'top' },
tooltip: {
backgroundColor: chartBgColor,
titleColor: labelColor,
bodyColor: labelColor,
callbacks: { label: ctx => ctx.dataset.label + ': $' + ctx.parsed.x.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }
}
},
scales: {
x: { beginAtZero: true, grid: { color: gridColor }, ticks: { color: labelColor, callback: v => '$' + (v/1000).toFixed(0) + 'k' } },
y: { grid: { display: false }, ticks: { color: labelColor } }
}
}
});
}
// ── CUSTOMER RETENTION DONUT ──────────────────────────────────────────────
if (document.getElementById('retentionDonutChart')) {
new Chart(document.getElementById('retentionDonutChart'), {
type: 'doughnut',
data: {
labels: ['Active', 'At Risk', 'Lapsing', 'Churned', 'Never Ordered'],
datasets: [{
data: [
@Model.CustomerRetention.Count(r => r.RetentionStatus == "Active"),
@Model.CustomerRetention.Count(r => r.RetentionStatus == "At Risk"),
@Model.CustomerRetention.Count(r => r.RetentionStatus == "Lapsing"),
@Model.CustomerRetention.Count(r => r.RetentionStatus == "Churned"),
@Model.CustomerRetention.Count(r => r.RetentionStatus == "Never Ordered")
],
backgroundColor: ['#10b981','#f59e0b','#f97316','#ef4444','#6b7280'],
borderWidth: 2,
borderColor: chartBgColor
}]
},
options: {
responsive: true,
cutout: '65%',
plugins: {
legend: { position: 'bottom', labels: { color: labelColor, padding: 12 } },
tooltip: {
backgroundColor: chartBgColor,
titleColor: labelColor,
bodyColor: labelColor,
borderColor: gridColor,
borderWidth: 1
}
}
}
});
}
// ── JOB CYCLE TIME HORIZONTAL BAR ────────────────────────────────────────
if (document.getElementById('jobCycleTimeChart')) {
const jctLabels = @Json.Serialize(Model.JobCycleTime.Select(s => s.StatusName));
const jctAvg = @Json.Serialize(Model.JobCycleTime.Select(s => s.AvgDays));
const jctMin = @Json.Serialize(Model.JobCycleTime.Select(s => s.MinDays));
const jctMax = @Json.Serialize(Model.JobCycleTime.Select(s => s.MaxDays));
new Chart(document.getElementById('jobCycleTimeChart'), {
type: 'bar',
data: {
labels: jctLabels,
datasets: [
{ label: 'Avg Days', data: jctAvg, backgroundColor: 'rgba(79,70,229,0.8)', borderRadius: 4, borderColor: '#4f46e5', borderWidth: 1 },
{ label: 'Min Days', data: jctMin, backgroundColor: 'rgba(16,185,129,0.6)', borderRadius: 4, borderColor: '#10b981', borderWidth: 1 },
{ label: 'Max Days', data: jctMax, backgroundColor: 'rgba(239,68,68,0.5)', borderRadius: 4, borderColor: '#ef4444', borderWidth: 1 }
]
},
options: {
responsive: true,
indexAxis: 'y',
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { position: 'top', labels: { color: labelColor } },
tooltip: {
backgroundColor: chartBgColor, titleColor: labelColor, bodyColor: labelColor,
callbacks: { label: ctx => ctx.dataset.label + ': ' + ctx.parsed.x.toFixed(1) + 'd' }
}
},
scales: {
x: { beginAtZero: true, grid: { color: gridColor }, ticks: { color: labelColor, callback: v => v + 'd' } },
y: { grid: { display: false }, ticks: { color: labelColor } }
}
}
});
}
// ── INVOICE AGING BAR ─────────────────────────────────────────────────────
if (document.getElementById('invoiceAgingChart')) {
@{
var agingLabels = new[] { "Current", "130 Days", "3160 Days", "6190 Days", "90+ Days" };
var agingTotals = agingLabels.Select(b =>
Model.InvoiceAgingDetail.Where(i => i.AgingBucket == b).Sum(i => i.BalanceDue)).ToArray();
}
new Chart(document.getElementById('invoiceAgingChart'), {
type: 'bar',
data: {
labels: @Json.Serialize(agingLabels),
datasets: [{
label: 'Balance Due',
data: @Json.Serialize(agingTotals),
backgroundColor: ['#10b981','#f59e0b','#f97316','#ef4444','#b91c1c'],
borderRadius: 4,
borderWidth: 0
}]
},
options: {
responsive: true,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: chartBgColor, titleColor: labelColor, bodyColor: labelColor,
callbacks: { label: ctx => '$' + ctx.parsed.y.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }
}
},
scales: {
x: { grid: { display: false }, ticks: { color: labelColor } },
y: { beginAtZero: true, grid: { color: gridColor }, ticks: { color: labelColor, callback: v => '$' + (v/1000).toFixed(0) + 'k' } }
}
}
});
}
// ── POWDER CONSUMPTION GROUPED BAR ───────────────────────────────────────
if (document.getElementById('powderConsumptionChart')) {
const pcLabels = @Json.Serialize(Model.PowderConsumption.Take(10).Select(p => p.ColorName ?? p.ItemName));
const pcPurchased = @Json.Serialize(Model.PowderConsumption.Take(10).Select(p => p.TotalPurchasedLbs));
const pcConsumed = @Json.Serialize(Model.PowderConsumption.Take(10).Select(p => p.TotalConsumedLbs));
new Chart(document.getElementById('powderConsumptionChart'), {
type: 'bar',
data: {
labels: pcLabels,
datasets: [
{ label: 'Purchased (lbs)', data: pcPurchased, backgroundColor: 'rgba(79,70,229,0.7)', borderColor: '#4f46e5', borderWidth: 1, borderRadius: 4 },
{ label: 'Consumed (lbs)', data: pcConsumed, backgroundColor: 'rgba(16,185,129,0.7)', borderColor: '#10b981', borderWidth: 1, borderRadius: 4 }
]
},
options: {
responsive: true,
indexAxis: 'y',
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { position: 'top', labels: { color: labelColor } },
tooltip: {
backgroundColor: chartBgColor, titleColor: labelColor, bodyColor: labelColor,
callbacks: { label: ctx => ctx.dataset.label + ': ' + ctx.parsed.x.toFixed(1) + ' lbs' }
}
},
scales: {
x: { beginAtZero: true, grid: { color: gridColor }, ticks: { color: labelColor, callback: v => v + ' lbs' } },
y: { grid: { display: false }, ticks: { color: labelColor } }
}
}
});
}
// ── AR Follow-up Email Draft ──────────────────────────────────────────────
document.querySelectorAll('.draft-email-btn').forEach(function (btn) {
btn.addEventListener('click', async function () {
const customerName = this.dataset.customer;
const amountOwed = parseFloat(this.dataset.amount) || 0;
const invoiceCount = parseInt(this.dataset.invoices) || 1;
const modal = new bootstrap.Modal(document.getElementById('arEmailModal'));
document.getElementById('arEmailSubject').value = '';
document.getElementById('arEmailBody').value = '';
document.getElementById('arEmailModalCustomer').textContent = customerName;
document.getElementById('arEmailStatus').textContent = 'Drafting email with AI...';
document.getElementById('arEmailContent').style.display = 'none';
modal.show();
try {
const resp = await fetch('/Reports/DraftArFollowUp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
customerName: customerName,
companyName: '',
amountOwed: amountOwed,
daysOverdue: 30,
invoices: [{ invoiceNumber: invoiceCount + ' invoice(s)', amount: amountOwed, daysOverdue: 30 }]
})
});
const data = await resp.json();
if (data.success) {
document.getElementById('arEmailSubject').value = data.subject;
document.getElementById('arEmailBody').value = data.body;
document.getElementById('arEmailStatus').textContent = '';
document.getElementById('arEmailContent').style.display = '';
} else {
document.getElementById('arEmailStatus').textContent = data.errorMessage || 'Could not draft email.';
}
} catch (e) {
document.getElementById('arEmailStatus').textContent = 'Error contacting AI service.';
}
});
});
document.getElementById('arEmailCopySubjectBtn')?.addEventListener('click', function () {
const v = document.getElementById('arEmailSubject').value;
navigator.clipboard.writeText(v).then(() => { this.textContent = 'Copied!'; setTimeout(() => { this.textContent = 'Copy Subject'; }, 1500); });
});
document.getElementById('arEmailCopyBodyBtn')?.addEventListener('click', function () {
const v = document.getElementById('arEmailBody').value;
navigator.clipboard.writeText(v).then(() => { this.textContent = 'Copied!'; setTimeout(() => { this.textContent = 'Copy Body'; }, 1500); });
});
// ── AI Financial Summary ─────────────────────────────────────────────────
document.getElementById('aiSummaryBtn')?.addEventListener('click', async function () {
const btn = this;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Generating...';
document.getElementById('aiSummaryContent').textContent = 'Analyzing your financial data...';
document.getElementById('aiSummaryContent').style.display = '';
document.getElementById('aiSummaryBullets').style.display = 'none';
document.getElementById('aiSummarySentimentBadge').style.display = 'none';
try {
const resp = await fetch('/Reports/GenerateFinancialSummary', { method: 'POST' });
const data = await resp.json();
if (data.success && data.bullets && data.bullets.length > 0) {
document.getElementById('aiSummaryContent').style.display = 'none';
const ul = document.getElementById('aiSummaryBullets');
ul.innerHTML = '';
data.bullets.forEach(b => {
const li = document.createElement('li');
li.textContent = b;
li.className = 'mb-1';
ul.appendChild(li);
});
ul.style.display = '';
const badge = document.getElementById('aiSummarySentimentBadge');
badge.style.display = '';
const sentiment = (data.sentiment || 'neutral').toLowerCase();
badge.textContent = sentiment.charAt(0).toUpperCase() + sentiment.slice(1);
badge.className = 'badge ms-2';
if (sentiment === 'positive') {
badge.classList.add('bg-success');
document.getElementById('aiSummaryCard').style.borderLeft = '4px solid #10b981';
} else if (sentiment === 'concerning') {
badge.classList.add('bg-danger');
document.getElementById('aiSummaryCard').style.borderLeft = '4px solid #ef4444';
} else {
badge.classList.add('bg-secondary');
document.getElementById('aiSummaryCard').style.borderLeft = '4px solid #6b7280';
}
} else {
document.getElementById('aiSummaryContent').textContent = data.errorMessage || 'Could not generate summary.';
document.getElementById('aiSummaryContent').style.display = '';
}
} catch (e) {
document.getElementById('aiSummaryContent').textContent = 'Error contacting AI service.';
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-stars me-1"></i>Regenerate';
}
});
</script>
}
<!-- AR Email Draft Modal -->
<div class="modal fade" id="arEmailModal" tabindex="-1" aria-labelledby="arEmailModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="arEmailModalLabel">
<i class="bi bi-envelope me-2"></i>Draft Follow-up Email
<span class="text-muted fw-normal small ms-2">for <span id="arEmailModalCustomer"></span></span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="arEmailStatus" class="text-muted small mb-3"></div>
<div id="arEmailContent" style="display:none;">
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<label class="form-label fw-medium mb-0">Subject</label>
<button type="button" class="btn btn-xs btn-sm btn-outline-secondary py-0 px-2" id="arEmailCopySubjectBtn">Copy Subject</button>
</div>
<input type="text" class="form-control" id="arEmailSubject" readonly />
</div>
<div class="mb-2">
<div class="d-flex justify-content-between align-items-center mb-1">
<label class="form-label fw-medium mb-0">Body</label>
<button type="button" class="btn btn-xs btn-sm btn-outline-secondary py-0 px-2" id="arEmailCopyBodyBtn">Copy Body</button>
</div>
<textarea class="form-control font-monospace small" id="arEmailBody" rows="12" readonly></textarea>
</div>
<p class="text-muted small mb-0"><i class="bi bi-info-circle me-1"></i>Review and edit in your email client before sending.</p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>