MobileShop Website Part 25 | Manage Product List in ASP.NET Core MVC

 


Welcome to Part 25 of our series! Now that we have fully completed our Category Management module, we are moving on to the heart of our e-commerce platform: Product Management.

In Step 1, we are going to create the administrative command center for our inventory—the ProductsController—inside the Admin Area.

Step 1: Secure Product Management Scaffolding

Just like we did for categories, we need to establish a secure, isolated controller structure before we write our CRUD action methods. This controller will handle all administrative product tasks such as checking stock, uploading product images, setting prices, and assigning products to categories.

C# / Areas/Admin/Controllers/ProductsController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace YourProjectNamespace.Areas.Admin.Controllers
{
    [Area("Admin")]
    [Authorize(Roles = "Admin")]
    public class ProductsController : Controller
    {
        private readonly ApplicationDbContext _context;

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

The Initial Step 1 Code Structure

Create a new controller named ProductsController.cs inside your Areas/Admin/Controllers/

Architectural Blueprint Explained

  • Area Isolation ([Area("Admin")]): This attribute tells the framework's routing engine that this controller belongs specifically to the administrative ecosystem. It separates your product catalog management endpoints from public-facing customer store routes.

  • Role-Based Access Control ([Authorize(Roles = "Admin")]): Products hold pricing, profit margins, and cost data. By enforcing this class-level guardrail, the system blocks unauthorized users or malicious bots from tampering with your store inventory or executing unintended updates.

  • Data Context Dependency Injection: By declaring a private readonly ApplicationDbContext _context and initializing it right through the constructor, we follow clean architectural design principles. This injects our database context safely, giving every future action method a direct, secure channel to read and write product information.

In Step 2, we implement a high-performance Index action method. Managing a modern inventory requires more than just loading a raw dump of rows. This logic introduces Eager Loading, dynamic multi-parameter LINQ Filtering, and database-level Pagination to keep your administration panel fast even with thousands of products.

Step 2: Multi-Filter Search and Pagination Pipeline

This index method combines multiple incoming search criteria and processes them inside SQL Server using deferred execution before returning a specific slice of data to the view.

C# / Areas/Admin/Controllers/ProductsController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace YourProjectNamespace.Areas.Admin.Controllers
{
    [Area("Admin")]
    [Authorize(Roles = "Admin")]
    public class ProductsController : Controller
    {
        private readonly ApplicationDbContext _context;

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

        // GET: Admin/Products
        public async Task<IActionResult> Index(string? search, int? categoryId, int? brandId, int page = 1)
        {
            // Build deferred execution query including relationships
            var query = _context.Products
                .Include(p => p.Category)
                .Include(p => p.Brand)
                .AsQueryable();

            // Apply Search Filtering
            if (!string.IsNullOrWhiteSpace(search))
            {
                query = query.Where(p => p.Name.Contains(search) || p.Model.Contains(search));
            }

            // Apply Category Filtering
            if (categoryId.HasValue)
            {
                query = query.Where(p => p.CategoryId == categoryId.Value);
            }

            // Apply Brand Filtering
            if (brandId.HasValue)
            {
                query = query.Where(p => p.BrandId == brandId.Value);
            }

            // Pagination Settings
            var pageSize = 20;
            var totalItems = await query.CountAsync();
            
            var products = await query
                .OrderByDescending(p => p.CreatedAt)
                .Skip((page - 1) * pageSize)
                .Take(pageSize)
                .ToListAsync();

            // Populate Lookup Data and State for the View
            ViewBag.Categories = await _context.Categories.Where(c => c.IsActive).ToListAsync();
            ViewBag.Brands = await _context.Brands.Where(b => b.IsActive).ToListAsync();
            ViewBag.CurrentPage = page;
            ViewBag.TotalPages = (int)Math.Ceiling(totalItems / (double)pageSize);
            ViewBag.Search = search;

            return View(products);
        }
    }
}

Core Logic & LINQ Processing Explained

  • Eager Loading Relational Data (.Include):

    var query = _context.Products.Include(p => p.Category).Include(p => p.Brand).AsQueryable();

    By default, Entity Framework will not fetch linked relational tables. We use .Include() to tell EF Core to write SQL JOIN statements. This fetches the Category Name and Brand Name alongside each product in a single database round-trip, completely avoiding the costly "N+1 query performance problem." .AsQueryable() keeps this query open for further modifications.

  • Dynamic Query Accumulation (Deferred Execution): Notice that our search, category, and brand filters are wrapped in conditional if blocks. Because query is an IQueryable, code like query.Where(...) does not run against the database immediately. It appends criteria to an internal SQL script builder, optimizing the query before execution.

  • Server-Side Pagination Math (.Skip and .Take):

    .Skip((page - 1) * pageSize).Take(pageSize)

    Instead of pulling 10,000 products into web server memory and slicing them on the screen, pagination slices records directly inside the database engine:

    • CountAsync(): Determines the total matching records to calculate the layout pagination buttons.

    • Skip(): Drops previous pages' records (e.g., if on page 3, it skips (3 - 1) * 20 = 40 products).

    • Take(): Grabs exactly the pageSize limit (20 products) for the active display.

  • View Layout Context Delivery (ViewBag): To construct drop-down search selectors in the frontend view, we fetch list collections for active Categories and Brands, sending them through ViewBag alongside the calculated tracking values like CurrentPage and TotalPages.

In Step 3, we build out the complex Product List Razor View. This layout acts as the dashboard where store managers can search your inventory, quickly spot low-stock items, compare pricing rules, and move between multiple data pages seamlessly.

Step 3: Product Inventory Filtering & Display Interface

This design balances massive data transparency with clean, context-aware interface elements to make warehouse management completely foolproof.

HTML / Views/Products/Index.cshtml
@model List<Product>
@{
    ViewData["Title"] = "Products";
}

<div class="d-flex justify-content-between align-items-center mb-4">
    <h3><i class="bi bi-phone"></i> Products</h3>
    <div>
        <a class="btn btn-success"><i class="bi bi-file-earmark-excel"></i> Export</a>
        <button type="button" class="btn btn-info">
            <i class="bi bi-upload"></i> Import
        </button>
        <a class="btn btn-primary"><i class="bi bi-plus"></i> Add Product</a>
    </div>
</div>

<!-- Filters -->
<div class="card mb-4">
    <div class="card-body">
        <form method="get" class="row g-3">
            <div class="col-md-4">
                <input type="text" name="search" class="form-control" placeholder="Search products..." value="@ViewBag.Search" />
            </div>
            <div class="col-md-3">
                <select name="categoryId" class="form-select">
                    <option value="">All Categories</option>
                    @foreach (var category in ViewBag.Categories)
                    {
                        <option value="@category.Id">@category.Name</option>
                    }
                </select>
            </div>
            <div class="col-md-3">
                <select name="brandId" class="form-select">
                    <option value="">All Brands</option>
                    @foreach (var brand in ViewBag.Brands)
                    {
                        <option value="@brand.Id">@brand.Name</option>
                    }
                </select>
            </div>
            <div class="col-md-2">
                <button type="submit" class="btn btn-outline-primary w-100"><i class="bi bi-search"></i> Filter</button>
            </div>
        </form>
    </div>
</div>

<div class="card shadow-sm">
    <div class="table-responsive">
        <table class="table table-hover mb-0">
            <thead class="table-dark">
                <tr>
                    <th>Image</th>
                    <th>Name</th>
                    <th>Brand</th>
                    <th>Category</th>
                    <th>Price</th>
                    <th>Stock</th>
                    <th>Featured</th>
                    <th>Actions</th>
                </tr>
            </thead>
            <tbody>
                @foreach (var product in Model)
                {
                    <tr>
                        <td>
                            <img src="@(product.MainImageUrl ?? "https://via.placeholder.com/50x50?text=No+Image")" 
                                 style="width: 50px; height: 50px; object-fit: cover;" class="rounded" />
                        </td>
                        <td>
                            <strong>@product.Name</strong>
                            <br /><small class="text-muted">@product.Model</small>
                        </td>
                        <td>@product.Brand?.Name</td>
                        <td>@product.Category?.Name</td>
                        <td>
                            @if (product.OriginalPrice > product.SalePrice)
                            {
                                <del class="text-muted small">RS @product.OriginalPrice.ToString("N0")</del>
                            }
                            <br />RS @product.SalePrice.ToString("N0")
                        </td>
                        <td>
                            @if (product.StockQuantity <= 10)
                            {
                                <span class="badge bg-danger">@product.StockQuantity</span>
                            }
                            else
                            {
                                <span class="badge bg-success">@product.StockQuantity</span>
                            }
                        </td>
                        <td>
                            @if (product.IsFeatured)
                            {
                                <i class="bi bi-check-circle-fill text-success"></i>
                            }
                            else
                            {
                                <i class="bi bi-x-circle-fill text-muted"></i>
                            }
                        </td>
                        <td>
                            <a class="btn btn-sm btn-outline-info">
                                <i class="bi bi-eye"></i>
                            </a>
                            <a class="btn btn-sm btn-outline-primary">
                                <i class="bi bi-pencil"></i>
                            </a>
                            <form method="post" class="d-inline" onsubmit="return confirm('Are you sure?');">
                                <input type="hidden" name="id" value="@product.Id" />
                                <button type="submit" class="btn btn-sm btn-outline-danger">
                                    <i class="bi bi-trash"></i>
                                </button>
                            </form>
                        </td>
                    </tr>
                }
            </tbody>
        </table>
    </div>
</div>

@if (ViewBag.TotalPages > 1)
{
    <nav class="mt-3">
        <ul class="pagination justify-content-center">
            @for (int i = 1; i <= ViewBag.TotalPages; i++)
            {
                <li class="page-item @(i == ViewBag.CurrentPage ? "active" : "")">
                    <a class="page-link" asp-action="Index" asp-route-page="@i" asp-route-search="@ViewBag.Search">@i</a>
                </li>
            }
        </ul>
    </nav>
}

Core UI Mechanics & Layout Features Explained

  • GET-Based Search Form (method="get"): Unlike your creation data panels that use POST actions to save data, our search form uses method="get". This ensures that all filter parameters (search keyword, category ID, and brand ID) append cleanly to the browser URL bar like an address line (?search=iPhone&categoryId=3). This lets administrators bookmark specific filtered pages or share them with other team members.

  • Null-Safe Image Fallbacks:

    <img src="@(product.MainImageUrl ?? "https://via.placeholder.com/50x50?text=No+Image")" />

    If a new mobile phone record is added before the creative design team uploads the photo asset, the C# null-coalescing operator (??) automatically catches the blank data string. It falls back to a clean placeholder image, keeping your text column alignments square.

  • Dynamic Markdown Retail Price Calculations (<del>):

    @if (product.OriginalPrice > product.SalePrice) {
        <del class="text-muted small">RS @product.OriginalPrice.ToString("N0")</del>
    }
    

    The view executes a live mathematical check on price fields. If an item is on sale, it renders the original cost with a standard HTML strike-through tag (<del>), placing the active SalePrice directly underneath it formatted using the "N0" currency thousands-separator layout.

  • Low-Stock Alert Threshold Visual Badges: To speed up re-ordering times, a conditional structural block monitors incoming inventory counts:

    • bg-danger: Triggers a bright red indicator bubble when inventory drops to 10 items or lower to signal an immediate re-order state.

    • bg-success: Displays a calm green indicator bubble when warehouse stocks are secure.

  • Sticky Parameter Multi-Page Pagination Layout:

    asp-route-page="@i" asp-route-search="@ViewBag.Search"

    Standard pagination components often lose tracking variables when an admin clicks from page 1 to page 2. This structure carries your state variables forward by explicitly packing @ViewBag.Search parameters into the Razor anchor routing tags, keeping filter restrictions locked as you navigate between data pages.

Comments