MobileShop Website Part 32 | Build Sales & Inventory Reports in ASP.NET Core MVC


 Welcome to Part 32 of our development series! Today we are entering a major new phase of our MobileShop project: Business Intelligence & Reporting.

In Step 1, we design a decoupled reporting layer by implementing the IReportService interface and its concrete class, ReportService. This service handles the analytical data gathering for our business dashboard—calculating historical sales revenue trends, managing real-time warehouse inventory thresholds, and tracking top-selling products using advanced aggregation techniques.

Here is the step-by-step breakdown of how this server-side reporting service processes your business metrics.

Step 1: Reporting Architecture

The Business Intelligence Aggregation Engine

C# / Services/ReportService.cs
using Microsoft.EntityFrameworkCore;
using MobileShop.Data;
using MobileShop.Models;
using MobileShop.ViewModels;

namespace MobileShop.Services
{
    public interface IReportService
    {
        Task<SalesReportViewModel> GetSalesReportAsync(DateTime startDate, DateTime endDate);
        Task<InventoryReportViewModel> GetInventoryReportAsync();
        Task<List<TopProductViewModel>> GetTopSellingProductsAsync(int count = 10);
    }

    public class ReportService : IReportService
    {
        private readonly ApplicationDbContext _context;

        public ReportService(ApplicationDbContext context)
        {
            _context = context;
        }

        public async Task<SalesReportViewModel> GetSalesReportAsync(DateTime startDate, DateTime endDate)
        {
            var orders = await _context.Orders
                .Where(o => o.OrderDate >= startDate && o.OrderDate <= endDate)
                .ToListAsync();

            var totalSales = orders
                .Where(o => o.PaymentStatus == PaymentStatus.Paid)
                .Sum(o => o.TotalAmount);

            var dailySales = orders
                .GroupBy(o => o.OrderDate.Date)
                .Select(g => new DailySalesViewModel
                {
                    Date = g.Key,
                    Sales = g.Where(o => o.PaymentStatus == PaymentStatus.Paid).Sum(o => o.TotalAmount),
                    OrderCount = g.Count()
                })
                .OrderBy(ds => ds.Date)
                .ToList();

            return new SalesReportViewModel
            {
                StartDate = startDate,
                EndDate = endDate,
                TotalSales = totalSales,
                TotalOrders = orders.Count,
                AverageOrderValue = orders.Any() ? totalSales / orders.Count : 0,
                DailySales = dailySales
            };
        }

        public async Task<InventoryReportViewModel> GetInventoryReportAsync()
        {
            var totalProducts = await _context.Products.CountAsync();
            var lowStockProducts = await _context.Products
                .Where(p => p.StockQuantity <= 10 && p.StockQuantity > 0)
                .Select(p => new LowStockProductViewModel
                {
                    ProductId = p.Id,
                    ProductName = p.Name,
                    CurrentStock = p.StockQuantity
                })
                .ToListAsync();

            var outOfStockCount = await _context.Products
                .CountAsync(p => p.StockQuantity == 0);

            return new InventoryReportViewModel
            {
                TotalProducts = totalProducts,
                LowStockCount = lowStockProducts.Count,
                OutOfStockCount = outOfStockCount,
                LowStockProducts = lowStockProducts
            };
        }

        public async Task<List<TopProductViewModel>> GetTopSellingProductsAsync(int count = 10)
        {
            return _context.OrderItems
                .Include(oi => oi.Product)
                .GroupBy(oi => new { oi.ProductId, oi.Product.Name })
                .AsEnumerable()
                .Select(g => new TopProductViewModel
                {
                    ProductId = g.Key.ProductId,
                    ProductName = g.Key.Name,
                    UnitsSold = g.Sum(oi => oi.Quantity),
                    Revenue = g.Sum(oi => oi.TotalPrice)
                })
                .OrderByDescending(tp => tp.UnitsSold)
                .Take(count)
                .ToList();
        }
    }
}

Step-by-Step Code Explanation

The Interface Definition (IReportService)

public interface IReportService
{
    Task<SalesReportViewModel> GetSalesReportAsync(DateTime startDate, DateTime endDate);
    Task<InventoryReportViewModel> GetInventoryReportAsync();
    Task<List<TopProductViewModel>> GetTopSellingProductsAsync(int count = 10);
}
  • Decoupling Strategy: By establishing an interface first, we decouple our business reporting logic from our controllers. This keeps the application maintainable, allows for easy Dependency Injection (DI), and makes unit testing much simpler.

  • All methods use an asynchronous design (Task<>) to ensure that heavy data calculations won't freeze background threads or bottleneck database operations.

Sales Report Analytics Engine

public async Task<SalesReportViewModel> GetSalesReportAsync(DateTime startDate, DateTime endDate)
{
    var orders = await _context.Orders
        .Where(o => o.OrderDate >= startDate && o.OrderDate <= endDate)
        .ToListAsync();
  • Date Range Filtering: Filters orders instantly at the database level so the system only pulls invoices that fall between the chosen startDate and endDate.

  • Revenue Accounting Guard:

    var totalSales = orders.Where(o => o.PaymentStatus == PaymentStatus.Paid).Sum(o => o.TotalAmount);
    

    This is a critical business rule logic check. We filter by PaymentStatus.Paid to prevent pending, failed, or fraudulent checkout attempts from skewing our actual accounting revenue metrics.

  • Daily Sales Trend Generation:

    var dailySales = orders.GroupBy(o => o.OrderDate.Date)
        .Select(g => new DailySalesViewModel { ... })
    

    Groups the matching order records by their base date value (.Date drops the timestamp details) so it can aggregate total sales numbers and order frequencies day-by-day. This provides a clean dataset for rendering front-end charts or line graphs later on.

Real-Time Inventory Control Metrics

public async Task<InventoryReportViewModel> GetInventoryReportAsync()
{
    var totalProducts = await _context.Products.CountAsync();
    var lowStockProducts = await _context.Products
        .Where(p => p.StockQuantity <= 10 && p.StockQuantity > 0)
        ...
  • Stock Health Tracking: Instantly counts total items and captures any products with stock levels between 1 and 10 units. This lets store managers quickly identify items that are running low and need to be restocked.

  • Out of Stock Alerting: Runs an efficient CountAsync lookup specifically for rows where StockQuantity == 0, providing a critical metric to track completely depleted inventory.

Bestsellers & Revenue Leaderboard

public async Task<List<TopProductViewModel>> GetTopSellingProductsAsync(int count = 10)
{
    return _context.OrderItems
        .Include(oi => oi.Product)
        .GroupBy(oi => new { oi.ProductId, oi.Product.Name })
  • Composite Key Grouping: Digs into the OrderItems table and groups records by both ProductId and Product.Name.

  • Metric Aggregation: For each grouped product bucket, it tallies up the absolute quantity of units sold via .Sum(oi => oi.Quantity) and tracks total generated sales revenue using .Sum(oi => oi.TotalPrice).

  • Top Truncation Optimization: Sorts the final leaderboard list in descending order based on volume sold (OrderByDescending) and applies a .Take(count) limit parameter. This optimizes system performance by only pulling the top 10 rows into memory, rather than loading thousands of low-volume products.

In Step 2 of Part 32, we take the reporting service we built in the previous step and expose it to our administrative panel by creating the SalesReport Action Method inside your DashboardController.

This method handles incoming user-selected date ranges, falls back to a smart default timeline if no dates are provided, calls our async report service, and passes the compiled analytics data directly to the front-end view.

Here is the step-by-step breakdown of how this controller action bridges your data layer to the user interface.

Step 2: Dashboard Controller Integration

The Request-to-View Controller Workflow

C# / Areas/Admin/Controllers/ReportsController.cs (SalesReport Method)
public async Task<IActionResult> SalesReport(DateTime? startDate, DateTime? endDate)
{
    var start = startDate ?? DateTime.Now.AddDays(-30);
    var end = endDate ?? DateTime.Now;

    var report = await _reportService.GetSalesReportAsync(start, end);
    return View(report);
}

Step-by-Step Code Explanation

Flexible Parameter Binding (Nullable DateTimes)

public async Task<IActionResult> SalesReport(DateTime? startDate, DateTime? endDate)
  • DateTime?: We purposefully declare the parameters as nullable types (using the ? modifier). This allows the action method to accept traffic from two different entry points:

    1. When an admin first loads the page without picking any dates yet (parameters arrive as null).

    2. When an admin uses a calendar date-picker to filter for a specific window (parameters arrive with explicit dates).

Smart Default Range Allocation (Null-Coalescing)

var start = startDate ?? DateTime.Now.AddDays(-30);
var end = endDate ?? DateTime.Now;
  • ?? (Null-Coalescing Operator): This checks if the incoming variables are null.

  • If a store manager just opened the page, startDate is null, so the system automatically defaults the start anchor to exactly 30 days ago (DateTime.Now.AddDays(-30)).

  • Similarly, if endDate is null, it defaults to the exact current day and time (DateTime.Now). This is a standard business dashboard pattern ensuring the manager is immediately presented with a fresh, rolling 30-day snapshot of their store's performance.

Service-Layer Data Processing

var report = await _reportService.GetSalesReportAsync(start, end);
  • Here, the controller calls the asynchronous GetSalesReportAsync method we injected from our IReportService.

  • By keeping the database heavy-lifting inside the service layer, the controller's only responsibility is handling the request parameters (start and end), keeping your code clean, highly maintainable, and lightweight.

Delivering the Payload to the UI

return View(report);
  • The generated SalesReportViewModel object (containing total sales, order counts, average ticket size, and a list of daily sales) is packaged up and passed directly into the corresponding Razor View (SalesReport.cshtml).

In Step 3 of Part 32, we are building the front-end user interface: the SalesReport.cshtml Razor View.

This file serves as our administrative dashboard layout. It handles high-level Key Performance Indicators (KPIs) through Bootstrap stat cards, contains a query filter toolbar to dynamically shift date bounds, draws a dynamic interactive trend chart via Chart.js, and displays an organized tabular data audit grid.

Here is the step-by-step structural breakdown of how this client-side template processes your compiled view model.

Step 3: Sales Dashboard UI Anatomy

The Dashboard Visual Hierarchy

CSHTML / Areas/Admin/Views/Reports/SalesReport.cshtml
@model SalesReportViewModel
@{
    ViewData["Title"] = "Sales Report";
}

<div class="d-flex justify-content-between align-items-center mb-4">
    <h3><i class="bi bi-graph-up"></i> Sales Report</h3>
    <form method="get" class="d-flex gap-2">
        <input type="date" name="startDate" class="form-control form-control-sm" value="@Model.StartDate.ToString("yyyy-MM-dd")" />
        <input type="date" name="endDate" class="form-control form-control-sm" value="@Model.EndDate.ToString("yyyy-MM-dd")" />
        <button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-search"></i></button>
    </form>
</div>

<div class="row g-4 mb-4">
    <div class="col-md-4">
        <div class="card bg-primary text-white">
            <div class="card-body">
                <h6>Total Sales</h6>
                <h3>RS @Model.TotalSales.ToString("N0")</h3>
            </div>
        </div>
    </div>
    <div class="col-md-4">
        <div class="card bg-info text-white">
            <div class="card-body">
                <h6>Total Orders</h6>
                <h3>@Model.TotalOrders</h3>
            </div>
        </div>
    </div>
    <div class="col-md-4">
        <div class="card bg-success text-white">
            <div class="card-body">
                <h6>Average Order Value</h6>
                <h3>RS @Model.AverageOrderValue.ToString("N0")</h3>
            </div>
        </div>
    </div>
</div>

<div class="card shadow-sm mb-4">
    <div class="card-header"><h5 class="mb-0">Daily Sales</h5></div>
    <div class="card-body">
        <canvas id="dailySalesChart" height="300"></canvas>
    </div>
</div>

<div class="card shadow-sm">
    <div class="card-header"><h5 class="mb-0">Daily Breakdown</h5></div>
    <div class="card-body">
        <div class="table-responsive">
            <table class="table table-hover">
                <thead><tr><th>Date</th><th>Orders</th><th>Sales</th></tr></thead>
                <tbody>
                    @foreach (var day in Model.DailySales)
                    {
                        <tr>
                            <td>@day.Date.ToString("MMM dd, yyyy")</td>
                            <td>@day.OrderCount</td>
                            <td>RS @day.Sales.ToString("N0")</td>
                        </tr>
                    }
                </tbody>
            </table>
        </div>
    </div>
</div>

@section Scripts {
    <script>
        var ctx = document.getElementById('dailySalesChart').getContext('2d');
        new Chart(ctx, {
            type: 'bar',
            data: {
                labels: [@Html.Raw(string.Join(",", Model.DailySales.Select(d => $"'{d.Date.ToString("MMM dd")}'")))],
                datasets: [{
                    label: 'Sales (RS )',
                    data: [@string.Join(",", Model.DailySales.Select(d => d.Sales))],
                    backgroundColor: 'rgba(52, 152, 219, 0.7)',
                    borderColor: 'rgba(52, 152, 219, 1)',
                    borderWidth: 1
                }]
            },
            options: { responsive: true, scales: { y: { beginAtZero: true } } }
        });
    </script>
}

Step-by-Step Code Explanation

The Model Bind & Dynamic Filter Header

@model SalesReportViewModel
...
<form method="get" class="d-flex gap-2">
    <input type="date" name="startDate" class="form-control form-control-sm" value="@Model.StartDate.ToString("yyyy-MM-dd")" />
    <input type="date" name="endDate" class="form-control form-control-sm" value="@Model.EndDate.ToString("yyyy-MM-dd")" />
    <button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-search"></i></button>
</form>
  • method="get": Crucial for filtering pages. Instead of submitting a hidden payload, it appends the selected dates directly to the browser URL string (e.g., ?startDate=2026-05-01&endDate=2026-05-31). This makes it easy for administrators to bookmark exact report intervals.

  • value="@Model.StartDate.ToString("yyyy-MM-dd")": Binds our backend's tracking window limits back into the HTML5 input boxes. This keeps the current active search filter range perfectly visible on-screen after the page reloads.

The Executive KPI Block (Stat Cards)

<h3>RS @Model.TotalSales.ToString("N0")</h3>
...
<h3>@Model.TotalOrders</h3>
...
<h3>RS @Model.AverageOrderValue.ToString("N0")</h3>
  • Uses responsive Bootstrap columns (col-md-4) to build three separate distinct metric containers matching a clean administrative workspace aesthetic.

  • ToString("N0"): Format utility string modifier that normalizes raw currency numbers by adding proper thousands commas separator markers (e.g., transforming 2500000 into a highly legible 2,500,000), adding instant visual polish to sales data.

The HTML5 Canvas Chart Anchor

<div class="card shadow-sm mb-4">
    <div class="card-header"><h5 class="mb-0">Daily Sales</h5></div>
    <div class="card-body">
        <canvas id="dailySalesChart" height="300"></canvas>
    </div>
</div>
  • Allocates an specialized HTML5 canvas area identifier element (id="dailySalesChart"). This empty container acts as a mounting grid layout target that our Chart.js script hook will intercept and draw over during page runtime.

 Tabular Data Breakdown Loop

@foreach (var day in Model.DailySales)
{
    <tr>
        <td>@day.Date.ToString("MMM dd, yyyy")</td>
        <td>@day.OrderCount</td>
        <td>RS @day.Sales.ToString("N0")</td>
    </tr>
}
  • Evaluates the nested DailySales collection model. For every individual day object captured inside our date boundaries, it constructs a clean row output displaying localized tracking timestamps (MMM dd, yyyy outputs clean strings like Jun 13, 2026), active loop frequencies, and explicit total numbers.

Chart.js Injected Script Architecture

@section Scripts {
    <script>
        var ctx = document.getElementById('dailySalesChart').getContext('2d');
        new Chart(ctx, {
            type: 'bar',
            data: {
                labels: [@Html.Raw(string.Join(",", Model.DailySales.Select(d => $"'{d.Date.ToString("MMM dd")}'")))],
                datasets: [{
                    label: 'Sales (RS )',
                    data: [@string.Join(",", Model.DailySales.Select(d => d.Sales))],
  • @section Scripts: Mandates that the script block is cleanly rendered at the very base of the site's layout page, ensuring the external Chart.js library handles dependencies efficiently before drawing graphics.

  • @Html.Raw(string.Join(...)): This is a brilliant integration trick. It uses LINQ projection to extract all the distinct dates from the model list, formats them as strings (like 'Jun 01','Jun 02'), and injects them directly into the JavaScript engine's x-axis labels configuration without HTML encoding safety distortions.

  • data: [@string.Join(...)]: Injects raw numerical sales values matching those identical dates as a basic, flat array sequence to map out the vertical column bars.

In Step 4 of Part 32, we expand our reporting module by exposing the real-time inventory metrics to our administration dashboard via the InventoryReport Action Method inside your DashboardController.

Compared to the sales report we built in the previous steps, the inventory report does not require custom date parameters. Instead, it serves as a live, real-time snapshot of your current warehouse stock levels. It queries your tracking service, compiles critical stock alerts, and pipes them directly to a dedicated manager layout view.

Here is the step-by-step structural breakdown of how this clean controller method operates.

Step 4: Inventory Controller Action Integration

The Real-Time Stock Monitoring Pipeline

C# / Areas/Admin/Controllers/ReportsController.cs (InventoryReport Method)
public async Task<IActionResult> InventoryReport()
{
    var report = await _reportService.GetInventoryReportAsync();
    return View(report);
}

Step-by-Step Code Explanation

Asynchronous Action Architecture

public async Task<IActionResult> InventoryReport()
  • async Task<IActionResult>: Operating with asynchronous patterns is vital here. Counting stock quantities across extensive product catalogs can trigger heavy table locks on your SQL database. Making this method async ensures the thread pool is unblocked while the database completes its calculations, keeping your application fast and scalable under heavy admin user load.

Service-Layer Isolation

var report = await _reportService.GetInventoryReportAsync();
  • The controller coordinates the request by executing the asynchronous GetInventoryReportAsync method we registered in our IReportService architecture back in Step 1.

  • By leaving the data-aggregation logic to the service layer, your controller is kept completely clean. It doesn't need to know how low stock is calculated (like our rule where stock $\le 10$ models triggers an alert); it simply collects the finished dataset package.

Delivering the Real-Time Payload to the View

return View(report);
  • The populated InventoryReportViewModel object is securely bound and sent down into the corresponding Razor View (InventoryReport.cshtml).

  • This view model includes high-level counts like:

    • TotalProducts: Total number of models listed in the system.

    • LowStockCount: Total number of items running dangerously low.

    • OutOfStockCount: Total number of items completely sold out.

    • LowStockProducts: A list collection containing the specific names and stock quantities of those low items for immediate restocking.

In Step 5 of Part 32, we implement the front-end user interface for our inventory tracking: the InventoryReport.cshtml Razor View.

This view functions as an automated warehouse health monitor. It consumes our live InventoryReportViewModel data graph, structures high-level stock counts using color-coded Bootstrap KPI summary cards, and uses logical evaluation blocks to render a clear, actionable data table listing products that require immediate replenishment.

Step 5: Inventory Report Dashboard UI Anatomy

The Visual Hierarchy Layout

CSHTML / Areas/Admin/Views/Reports/InventoryReport.cshtml
@model InventoryReportViewModel
@{
    ViewData["Title"] = "Inventory Report";
}

<div class="d-flex justify-content-between align-items-center mb-4">
    <h3><i class="bi bi-box-seam"></i> Inventory Report</h3>
</div>

<div class="row g-4 mb-4">
    <div class="col-md-4">
        <div class="card bg-primary text-white">
            <div class="card-body">
                <h6>Total Products</h6>
                <h3>@Model.TotalProducts</h3>
            </div>
        </div>
    </div>
    <div class="col-md-4">
        <div class="card bg-warning text-white">
            <div class="card-body">
                <h6>Low Stock</h6>
                <h3>@Model.LowStockCount</h3>
            </div>
        </div>
    </div>
    <div class="col-md-4">
        <div class="card bg-danger text-white">
            <div class="card-body">
                <h6>Out of Stock</h6>
                <h3>@Model.OutOfStockCount</h3>
            </div>
        </div>
    </div>
</div>

<div class="card shadow-sm">
    <div class="card-header"><h5 class="mb-0">Low Stock Products</h5></div>
    <div class="card-body">
        @if (Model.LowStockProducts.Count > 0)
        {
            <div class="table-responsive">
                <table class="table table-hover">
                    <thead><tr><th>Product ID</th><th>Product Name</th><th>Current Stock</th><th>Status</th></tr></thead>
                    <tbody>
                        @foreach (var product in Model.LowStockProducts)
                        {
                            <tr>
                                <td>@product.ProductId</td>
                                <td>@product.ProductName</td>
                                <td><span class="badge bg-warning">@product.CurrentStock</span></td>
                                <td>
                                    @if (product.CurrentStock == 0)
                                    {
                                        <span class="badge bg-danger">Out of Stock</span>
                                    }
                                    else
                                    {
                                        <span class="badge bg-warning">Low Stock</span>
                                    }
                                </td>
                            </tr>
                        }
                    </tbody>
                </table>
            </div>
        }
        else
        {
            <p class="text-muted mb-0">All products have sufficient stock.</p>
        }
    </div>
</div>

Step-by-Step UI Layout Explanation

Strongly Typed Model Binding & Header Context

@model InventoryReportViewModel
@{
    ViewData["Title"] = "Inventory Report";
}
<div class="d-flex justify-content-between align-items-center mb-4">
    <h3><i class="bi bi-box-seam"></i> Inventory Report</h3>
</div>
  • @model InventoryReportViewModel: Connects this markup view strictly to our curated inventory report data contract, giving the Razor compilation engine direct autocomplete properties for inventory lists.

  • bi bi-box-seam: Injects a clean Bootstrap Icon representing a cardboard shipping package, establishing a professional theme for stock-tracking pages.

Color-Coded Executive KPI Cards

<div class="card bg-primary text-white">...<h3>@Model.TotalProducts</h3></div>
<div class="card bg-warning text-white">...<h3>@Model.LowStockCount</h3></div>
<div class="card bg-danger text-white">...<h3>@Model.OutOfStockCount</h3></div>
  • This section leverages Bootstrap 5 row systems (row g-4 mb-4) to establish a clean structural overview grid.

  • The Visual Language Check: We intentionally map background color context classes (bg-*) to reflect data urgency levels:

    • bg-primary (Blue): Represents general metrics, displaying the total catalog size.

    • bg-warning (Yellow/Orange): Draws attention to the low-stock counter, indicating items that are running low.

    • bg-danger (Red): Signals an immediate emergency alert, highlighting items that are completely sold out and causing lost sales.

Dynamic Empty-State Condition Handling

@if (Model.LowStockProducts.Count > 0) { ... }
else { <p class="text-muted mb-0">All products have sufficient stock.</p> }
  • Before attempting to parse a data table on the screen, the view evaluates if there are any records inside the LowStockProducts collection loop.

  • If the list is empty (Count == 0), the table structure stays completely hidden. Instead, it renders a clean fallback paragraph informing managers that everything on the shelves is completely healthy, saving screen space and improving user experience.

The Core Alert Data Table & Conditional Status Badges

@foreach (var product in Model.LowStockProducts)
{
    <tr>
        <td>@product.ProductId</td>
        <td>@product.ProductName</td>
        <td><span class="badge bg-warning">@product.CurrentStock</span></td>
        <td>
            @if (product.CurrentStock == 0)
            {
                <span class="badge bg-danger">Out of Stock</span>
            }
            else { <span class="badge bg-warning">Low Stock</span> }
        </td>
    </tr>
}
  • Inside our data loop iteration, we display individual rows for each low-stock tracking item.

  • Nested Razor Logic Layer: Inside the final <td> column, the view evaluates the live @product.CurrentStock value inline. If the stock has fully dropped down to 0, it renders a bright red Out of Stock badge; if it is anywhere between 1 and 10, it labels it as Low Stock in yellow. This makes it incredibly easy for administrators to skim the sheet and instantly prioritize ordering items that are entirely sold out.

In Step 6 of Part 32, we implement the final core component of our business intelligence module: the TopProducts Action Method inside the administrative DashboardController.

This method handles requests for your platform's sales leaderboard. It instructs our analytical service layer to extract your top-performing products based on sales velocity and total gross revenue, handles tracking constraints, and forwards the collection to a storefront analytics dashboard view.

Here is the step-by-step structural breakdown of how this controller method operates.

Step 6: Bestsellers Leaderboard Controller Integration

The Leaderboard Aggregation Pipeline

C# / Areas/Admin/Controllers/ReportsController.cs (TopProducts Method)
public async Task<IActionResult> TopProducts()
{
    var products = await _reportService.GetTopSellingProductsAsync(20);
    return View(products);
}

Step-by-Step Code Explanation

Asynchronous Task Signature

public async Task<IActionResult> TopProducts()
  • Performance Architecture: Compiling data leaderboards across relational order histories requires scanning and summing up entire transactional index logs. Declaring this action method as an asynchronous Task keeps the application responsive and lightweight, ensuring your web server never bottlenecks thread resources while computing heavy statistics.

Constrained Range Ingestion Processing

var products = await _reportService.GetTopSellingProductsAsync(20);
  • The controller invokes the GetTopSellingProductsAsync method we registered in our IReportService architecture back in Step 1.

  • The Scale Parameter (20): Notice that we pass an explicit parameter integer value of 20 into the service method call. This overrides the default service fallback threshold of 10 items. This allows your primary dashboard home screen to show a tight "Top 10" snapshot, while this dedicated deep-dive reporting page presents an expanded list of your top 20 flagship items for deeper business analysis.

Delivering the Ranked List to the View

return View(products);
  • The compiled collection (List<TopProductViewModel>) is securely bound and sent down into the corresponding Razor View (TopProducts.cshtml).

  • Each individual data node passed inside this collection payload contains everything needed to render a rich leaderboard:

    • ProductId: The unique primary key index tracking the specific inventory item.

    • ProductName: The public title text string identifying the mobile device.

    • UnitsSold: The aggregated count of absolute hardware items processed through your checkouts.

    • Revenue: The gross calculated currency volume generated by those specific sales.

In Step 7 of Part 32, we implement the front-end user interface for our sales leaderboard: the TopProducts.cshtml Razor View.

This view functions as a high-end product analytics console. It handles a multi-axis data display, showing a main, visual comparison chart alongside a compact "Top 3 Podium" ranking sidebar list group. It also features a fallback detailed grid layout displaying units sold and total generated store revenue.

Step 7: Bestsellers Dashboard UI Anatomy

The Leaderboard Panel Layout Hierarchy

CSHTML / Areas/Admin/Views/Reports/TopProducts.cshtml
@model List<TopProductViewModel>
@{
    ViewData["Title"] = "Top Selling Products";
}

<div class="d-flex justify-content-between align-items-center mb-4">
    <h3><i class="bi bi-trophy"></i> Top Selling Products</h3>
</div>

<div class="row g-4">
    <div class="col-lg-8">
        <div class="card shadow-sm">
            <div class="card-header"><h5 class="mb-0">Top Products Chart</h5></div>
            <div class="card-body">
                <canvas id="topProductsChart" height="350"></canvas>
            </div>
        </div>
    </div>
    <div class="col-lg-4">
        <div class="card shadow-sm">
            <div class="card-header"><h5 class="mb-0">Rankings</h5></div>
            <div class="card-body p-0">
                <div class="list-group list-group-flush">
                    @for (int i = 0; i < Model.Count; i++)
                    {
                        <div class="list-group-item d-flex justify-content-between align-items-center">
                            <div class="d-flex align-items-center">
                                <span class="badge bg-@(i < 3 ? "warning" : "secondary") me-2">#@(i + 1)</span>
                                <div>
                                    <h6 class="mb-0">@Model[i].ProductName</h6>
                                    <small class="text-muted">@Model[i].UnitsSold units sold</small>
                                </div>
                            </div>
                            <span class="badge bg-success">RS @Model[i].Revenue.ToString("N0")</span>
                        </div>
                    }
                </div>
            </div>
        </div>
    </div>
</div>

<div class="card shadow-sm mt-4">
    <div class="card-header"><h5 class="mb-0">Detailed Data</h5></div>
    <div class="card-body">
        <div class="table-responsive">
            <table class="table table-hover">
                <thead><tr><th>Rank</th><th>Product</th><th>Units Sold</th><th>Revenue</th></tr></thead>
                <tbody>
                    @for (int i = 0; i < Model.Count; i++)
                    {
                        <tr>
                            <td><span class="badge bg-@(i < 3 ? "warning text-dark" : "secondary")">#@(i + 1)</span></td>
                            <td>@Model[i].ProductName</td>
                            <td>@Model[i].UnitsSold</td>
                            <td>RS @Model[i].Revenue.ToString("N0")</td>
                        </tr>
                    }
                </tbody>
            </table>
        </div>
    </div>
</div configurations>

@section Scripts {
    <script>
        var ctx = document.getElementById('topProductsChart').getContext('2d');
        new Chart(ctx, {
            type: 'bar',
            data: {
                labels: [@Html.Raw(string.Join(",", Model.Select(p => $"'{p.ProductName}'")))],
                datasets: [{
                    label: 'Units Sold',
                    data: [@string.Join(",", Model.Select(p => p.UnitsSold))],
                    backgroundColor: 'rgba(46, 204, 113, 0.7)',
                    borderColor: 'rgba(46, 204, 113, 1)',
                    borderWidth: 1
                }, {
                    label: 'Revenue (RS thousands)',
                    data: [@string.Join(",", Model.Select(p => (p.Revenue / 1000).ToString("F0")))],
                    backgroundColor: 'rgba(155, 89, 182, 0.5)',
                    borderColor: 'rgba(155, 89, 182, 1)',
                    borderWidth: 1,
                    yAxisID: 'y1'
                }]
            },
            options: {
                responsive: true,
                scales: {
                    y: { beginAtZero: true, position: 'left' },
                    y1: { beginAtZero: true, position: 'right', grid: { drawOnChartArea: false } }
                }
            }
        });
    </script>
}

Step-by-Step UI Layout Explanation

Multi-Column Dashboard Split

<div class="row g-4">
    <div class="col-lg-8"> </div>
    <div class="col-lg-4"> </div>
</div>
  • Uses an asymmetric Bootstrap 5 grid layout scheme (col-lg-8 and col-lg-4). This design prioritizes horizontal visual data charts in the wide section, while keeping quick list summaries accessible on the right side.

Conditional "Top 3 Podium" Styling Loop

@for (int i = 0; i < Model.Count; i++)
{
    <span class="badge bg-@(i < 3 ? "warning" : "secondary") me-2">#@(i + 1)</span>
}
  • Index Pointer Tracking (i + 1): Because standard C# loops are zero-indexed ($i = 0$), we display human-readable leaderboard values by adding an offset calculation (#@(i + 1)), which prints #1, #2, #3.

  • Ternary Context Color Class: We apply an intentional conditional check (i < 3 ? "warning" : "secondary"). This highlights your top 3 flagship items with a distinct gold badge (bg-warning), while assigning a neutral gray look (bg-secondary) to items ranked 4th and below.

Advanced Multi-Axis Chart.js Integration Script

labels: [@Html.Raw(string.Join(",", Model.Select(p => $"'{p.ProductName}'")))],
datasets: [{
    label: 'Units Sold',
    data: [@string.Join(",", Model.Select(p => p.UnitsSold))],
    backgroundColor: 'rgba(46, 204, 113, 0.7)' // Green
}, {
    label: 'Revenue (RS thousands)',
    data: [@string.Join(",", Model.Select(p => (p.Revenue / 1000).ToString("F0")))],
    backgroundColor: 'rgba(155, 89, 182, 0.5)', // Purple
    yAxisID: 'y1'
}]
  • Data Scale Mapping (p.Revenue / 1000): This addresses a common charting problem: charting a small number (like 50 units sold) alongside a massive number (like 1,500,000 RS in revenue) flattens the smaller bar completely. To solve this, we divide the revenue numbers by 1000 to scale down the bars, labeling the dataset explicitly as Revenue (RS thousands).

  • Dual Independent Y-Axes (yAxisID: 'y1'): To make the chart easy to read, we define a second, separate axis config rule down in the options parameters:

    scales: {
        y: { beginAtZero: true, position: 'left' },
        y1: { beginAtZero: true, position: 'right', grid: { drawOnChartArea: false } }
    }
    

    This creates a split chart: the left-hand Y-axis measures volume numbers for the green Units Sold columns, while the right-hand Y-axis maps monetary thresholds for the purple Revenue bars. This approach prevents data distortion and makes tracking performance simple.

In Step 8 of Part 32, we tie our entire business intelligence reporting module together by integrating it into the master _AdminLayout.cshtml navigation sidebar.

Instead of adding three separate, bulky links that take up valuable sidebar space, this code implements a clean, collapsible Dropdown Submenu using Bootstrap 5. It keeps the administrative cockpit organized by grouping all your analytical reports under a single "Reports" parent tab.

Here is the step-by-step structural breakdown of how this navigation menu operates.

Step 8: Collapsible Sidebar Menu Architecture

The Dropdown UI Hierarchy

HTML / Sidebar Reports Submenu Snippet
<li>
    <a href="#reportsSubmenu" data-bs-toggle="collapse" class="dropdown-toggle">
        <i class="bi bi-graph-up"></i> Reports
    </a>
    <ul class="collapse list-unstyled" id="reportsSubmenu">
        <li>
            <a asp-area="Admin" asp-controller="Dashboard" asp-action="SalesReport">Sales Report</a>
        </li>
        <li>
            <a asp-area="Admin" asp-controller="Dashboard" asp-action="InventoryReport">Inventory Report</a>
        </li>
        <li>
            <a asp-area="Admin" asp-controller="Dashboard" asp-action="TopProducts">Top Products</a>
        </li>
    </ul>
</li>

Step-by-Step Code Explanation

The Parent Toggle Trigger

<a href="#reportsSubmenu" data-bs-toggle="collapse" class="dropdown-toggle">
    <i class="bi bi-graph-up"></i> Reports
</a>
  • data-bs-toggle="collapse": This native Bootstrap 5 data attribute tells the client-side JavaScript engine to treat this anchor tag as a toggle switch for a collapsible container instead of a traditional page hyperlink.

  • href="#reportsSubmenu": Specifies the exact target ID that this button is responsible for opening and closing. The # symbol indicates a CSS ID selector match.

  • class="dropdown-toggle": Automatically adds a small arrow chevron indicator ($\lor$) next to the word "Reports", signaling to the admin that this item expands into a sub-list.

The Hidden Submenu Container

<ul class="collapse list-unstyled" id="reportsSubmenu">
  • class="collapse": This Bootstrap layout utility ensures that the sub-menu container stays completely hidden out of sight by default when the administrative dashboard first loads.

  • id="reportsSubmenu": This bridges the toggle to the list. This identity token matches the parent's href parameter exactly. When the parent link is clicked, Bootstrap smoothly animates this specific <ul> block open or closed.

  • list-unstyled: Strips away standard HTML default bullet points, allowing you to style the nested options cleanly.

Strongly Typed Area & Route Tag Helpers

<a asp-area="Admin" asp-controller="Dashboard" asp-action="SalesReport">Sales Report</a>
  • asp-area="Admin": This is highly important for large enterprise setups. It explicitly tells the routing engine to look inside your project's Admin Area architecture bundle rather than the default root layout folder.

  • asp-controller="Dashboard" & asp-action="...": Maps out the exact destination logic paths. The framework automatically parses these helper rules to generate clean, SEO-friendly target URLs at runtime:

    • Sales Report maps to /Admin/Dashboard/SalesReport

    • Inventory Report maps to /Admin/Dashboard/InventoryReport

    • Top Products maps to /Admin/Dashboard/TopProducts

Comments