64a9c1531b
Razor's @() expression auto-encodes &, turning — into &mdash; which rendered as literal text in the browser. Wrapped all such expressions in @Html.Raw() so the em-dash entity is passed through unescaped. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3411 lines
173 KiB
Plaintext
3411 lines
173 KiB
Plaintext
@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
|
|
<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 & 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 & 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 & Income</div>
|
|
<div class="text-muted small">Revenue by customer & 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">@Html.Raw(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">@Html.Raw(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"><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">30–60 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">60–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-danger">@retChurned</div>
|
|
<div class="small text-muted">Churned</div>
|
|
<div class="small text-muted">>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">
|
|
@Html.Raw(r.LastJobDate.HasValue ? r.LastJobDate.Value.ToString("MMM d, yyyy") : "—")
|
|
</td>
|
|
<td class="text-center small">
|
|
@Html.Raw(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 (≥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 == "1–30 Days").ToList();
|
|
var iad31to60 = Model.InvoiceAgingDetail.Where(i => i.AgingBucket == "31–60 Days").ToList();
|
|
var iad61to90 = Model.InvoiceAgingDetail.Where(i => i.AgingBucket == "61–90 Days").ToList();
|
|
var iad90plus = Model.InvoiceAgingDetail.Where(i => i.AgingBucket == "90+ Days").ToList();
|
|
}
|
|
<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">1–30 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">31–60 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">61–90 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",
|
|
"1–30 Days" => "bg-warning text-dark",
|
|
"31–60 Days" => "badge-orange",
|
|
"61–90 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">@Html.Raw(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 == "31–60 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">@Html.Raw(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"><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">7–30 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">>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", "1–30 Days", "31–60 Days", "61–90 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>
|