Back to: ASP.NET Core Tutorials For Beginners and Professionals
Working with Bar Chart and PDF in ASP.NET Core MVC
In this article, I will discuss Working with Bar Chart and PDF from MVC Views in ASP.NET Core MVC Application with Examples using Rotativa.AspNetCore Library. Please read our previous article discussing How to Generate Password Protected PDF in ASP.NET Core MVC Application with Examples.
Why Do We Need to Generate PDFs From MVC Views?
Generating PDFs from MVC views is useful when you want to provide users with a downloadable and printable version of a webpage or report. PDFs are a widely accepted format for sharing documents and reports across different platforms while maintaining the layout and design.
In simple words, generating a PDF from an MVC view is useful whenever you need a snapshot of data in a presentable, standardized format, especially for things like invoices, receipts, sales reports, etc. For example, generating a PDF sales report allows business users to archive, print, or share the report without relying on the web interface.
Real-time Application to Generate PDF from MVC Views:
We will develop an E-commerce Sales Reporting App. Its core functionalities include:
- Data Display: It fetches orders, customers, products, and order items from the SQL Server database using Entity Framework Core.
- Filtering & Pagination: Users can filter sales data based on date ranges, product categories, and order statuses. The results are paginated to improve usability.
- Data Visualization: The app displays a summary and a day-wise stacked bar chart with an overlaid line chart for total sales.
- PDF Export: The sales report can be exported as a PDF, which is convenient for sharing and offline usage.
- AJAX Modal: A modal dialog dynamically shows the details of ordered products using AJAX, enhancing the user experience.
Now, let us understand the flow from a web page point of view:
Search Filter:
Enables users to refine the sales data by selecting a date range, product category, and order status, ensuring the displayed report focuses only on relevant orders.
Summary and Bar Chart:
It presents overall metrics such as total orders, pending, completed, canceled, and total sales and visualizes daily order counts alongside sales trends through a stacked bar chart and line graph.
Order Details:
Lists filtered orders in a paginated table, showing key information such as order number, date, status, and total amount, with a quick option to export the report to PDF.
View Products Modal:
Shows a modal pop-up with product-level details for a selected order, including quantity, unit price, and line total, without navigating away from the main report.
Export to PDF:
When you click on the Export to PDF button, it will download the PDF as shown in the below image.
Create a New ASP.NET Core MVC Project and Install Packages
Open Visual Studio and create a new ASP.NET Core Web App (Model-View-Controller) project. Name the project ECommerceSalesApp. We will use Rotativa.AspNetCore package to generate PDF from HTML. So, please install the necessary NuGet packages using the Package Manager Console:
- Install-Package Microsoft.EntityFrameworkCore.SqlServer
- Install-Package Microsoft.EntityFrameworkCore.Tools
- Install-Package Rotativa.AspNetCore
Creating Models
Models represent the domain entities or data structures of the application. They define the properties and relationships that mirror your business data and are used to interact with the database. Please create a folder named Models in the project root directory if it does not exist.
Customer.cs
Create a file named Customer.cs in the Models folder, and then copy and paste the following code. It represents a customer with CustomerId, Name, and Email properties. It also includes a navigation property (Orders) representing the one-to-many relationship with orders.
using System.ComponentModel.DataAnnotations; namespace ECommerceSalesApp.Models { public class Customer { public int CustomerId { get; set; } [Required, StringLength(100)] public string Name { get; set; } [Required, StringLength(100)] public string Email { get; set; } // Navigation public List<Order> Orders { get; set; } } }
OrderStatus.cs
Create a file named OrderStatus.cs in the Models folder, and then copy and paste the following code. It is an enumeration that defines the possible states of an order (e.g., Pending, Completed, Cancelled).
namespace ECommerceSalesApp.Models { public enum OrderStatus { Pending = 1, Completed = 2, Cancelled = 3 } }
Order.cs
Create a file named Order.cs in the Models folder, and then copy and paste the following code. This entity represents an order placed by a customer. It includes properties like OrderId, OrderDate, OrderNumber, TotalAmount, and OrderStatus. It also defines relationships to the Customer (foreign key) and OrderItems.
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; namespace ECommerceSalesApp.Models { public class Order { public int OrderId { get; set; } [Required] public DateTime OrderDate { get; set; } [Required, StringLength(20)] public string OrderNumber { get; set; } // Foreign Key to Customer public int CustomerId { get; set; } public Customer Customer { get; set; } // Navigation public List<OrderItem> OrderItems { get; set; } // Optional convenience property to sum order details [Column(TypeName = "decimal(18,2)")] public decimal TotalAmount { get; set; } [Required] public OrderStatus OrderStatus { get; set; } = OrderStatus.Pending; } }
OrderItem.cs
Create a file named OrderItem.cs in the Models folder, and then copy and paste the following code. Represents individual items within an order. Each order item links to a specific product and contains details such as Quantity and UnitPrice.
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; namespace ECommerceSalesApp.Models { public class OrderItem { public int OrderItemId { get; set; } // Foreign Key to Order public int OrderId { get; set; } public Order Order { get; set; } // Foreign Key to Product public int ProductId { get; set; } public Product Product { get; set; } [Required] public int Quantity { get; set; } [Required] [Column(TypeName = "decimal(18,2)")] public decimal UnitPrice { get; set; } } }
Product.cs
Create a file named Product.cs in the Models folder, and then copy and paste the following code. This entity represents a product available in the e-commerce store, including its Name, Price, StockQuantity, and Category.
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; namespace ECommerceSalesApp.Models { public class Product { public int ProductId { get; set; } [Required, StringLength(150)] public string Name { get; set; } [Required] [Column(TypeName ="decimal(18,2)")] public decimal Price { get; set; } public int StockQuantity { get; set; } // Alternatively, you could create a dedicated Category entity/table. // But for simplicity, we will store category as a string. [Required, StringLength(100)] public string Category { get; set; } } }
Creating View Models
View Models are specialized classes that contain data tailored for rendering in views. They often combine information from multiple models and may include additional properties for UI-specific functionality (such as filtering options, summary values, or pagination data). Please create a folder named ViewModels in the project root directory.
SalesReportFilterViewModel
Create a file named SalesReportFilterViewModel.cs in the ViewModels folder, then copy and paste the following code. This view model holds filtering criteria (date range, category, order status) and lists of available options for each filter (e.g., list of categories, predefined date range options).
namespace ECommerceSalesApp.ViewModels { public class SalesReportFilterViewModel { public string DateRangeOption { get; set; } public DateTime? StartDate { get; set; } public DateTime? EndDate { get; set; } public string Category { get; set; } public string OrderStatus { get; set; } public List<string> Categories { get; set; } public List<string> OrderStatuses { get; set; } public List<string> DateRangeOptions { get; set; } = new List<string> { "All", "Today", "Yesterday", "Last 7 Days", "This Month", "Last Month", "Last 3 Months", "Last 6 Months", "Last 1 Year" }; } }
SalesReportViewModel
This view model is used to pass data from the controller to the view to display the sales report. It contains:
- A Filter property (an instance of SalesReportFilterViewModel)
- Summary values (total orders, pending, completed, canceled, total sales)
- A list of orders for the current page
- Chart data (as JSON) for rendering the bar and line chart
- Pagination properties (current page and total pages)
So, create a file named SalesReportViewModel.cs in the ViewModels folder, then copy and paste the following code.
using ECommerceSalesApp.ViewModels; namespace ECommerceSalesApp.Models { public class SalesReportViewModel { public SalesReportFilterViewModel Filter { get; set; } // Summary values public int TotalOrders { get; set; } public int TotalPending { get; set; } public int TotalCompleted { get; set; } public int TotalCanceled { get; set; } public decimal TotalSales { get; set; } // Orders for the current page public List<Order> Orders { get; set; } // Bar Chart Data (JSON) public string ChartDataJson { get; set; } // Pagination public int CurrentPage { get; set; } public int TotalPages { get; set; } } }
Creating DBContext Class
DbContext is the primary class in Entity Framework Core and is responsible for interacting with the database. It manages the database connection and is used to query and save data. First, create a folder named Data in the project root directory.
Create a class file named ECommerceDBContext.cs within the Data folder, and copy and paste the following code. This class defines DbSet properties for each model (Customers, Orders, OrderItems, Products), enabling CRUD operations on the database. It also includes the OnModelCreating method to seed initial data into the database, which is useful for testing and demonstration purposes.
using ECommerceSalesApp.Models; using Microsoft.EntityFrameworkCore; using System; namespace ECommerceSalesApp.Data { public class ECommerceDBContext : DbContext { public ECommerceDBContext(DbContextOptions<ECommerceDBContext> options) : base(options) { } protected override void OnModelCreating(ModelBuilder modelBuilder) { // SEED CUSTOMERS (if not already seeded) modelBuilder.Entity<Customer>().HasData( new Customer { CustomerId = 1, Name = "Alice", Email = "alice@example.com" }, new Customer { CustomerId = 2, Name = "Bob", Email = "bob@example.com" } ); // SEED PRODUCTS (with Category property assumed to be added) modelBuilder.Entity<Product>().HasData( new Product { ProductId = 1, Name = "Laptop", Price = 1200m, StockQuantity = 10, Category = "Electronics" }, new Product { ProductId = 2, Name = "Phone", Price = 800m, StockQuantity = 15, Category = "Electronics" }, new Product { ProductId = 3, Name = "Headphones", Price = 100m, StockQuantity = 30, Category = "Accessories" } ); // SEED 18 ORDERS with OrderNumber and various statuses/dates modelBuilder.Entity<Order>().HasData( new Order { OrderId = 1, OrderNumber = "ORD-0001", CustomerId = 1, OrderDate = DateTime.Today.AddDays(-1), TotalAmount = 1300m, OrderStatus = OrderStatus.Completed }, new Order { OrderId = 2, OrderNumber = "ORD-0002", CustomerId = 2, OrderDate = DateTime.Today.AddDays(-1), TotalAmount = 2200m, OrderStatus = OrderStatus.Pending }, new Order { OrderId = 3, OrderNumber = "ORD-0003", CustomerId = 1, OrderDate = DateTime.Today.AddDays(-3), TotalAmount = 500m, OrderStatus = OrderStatus.Cancelled }, new Order { OrderId = 4, OrderNumber = "ORD-0004", CustomerId = 2, OrderDate = DateTime.Today.AddDays(-3), TotalAmount = 1600m, OrderStatus = OrderStatus.Completed }, new Order { OrderId = 5, OrderNumber = "ORD-0005", CustomerId = 1, OrderDate = DateTime.Today.AddDays(-3), TotalAmount = 900m, OrderStatus = OrderStatus.Pending }, new Order { OrderId = 6, OrderNumber = "ORD-0006", CustomerId = 2, OrderDate = DateTime.Today.AddDays(-4), TotalAmount = 1800m, OrderStatus = OrderStatus.Completed }, new Order { OrderId = 7, OrderNumber = "ORD-0007", CustomerId = 1, OrderDate = DateTime.Today.AddDays(-5), TotalAmount = 1100m, OrderStatus = OrderStatus.Completed }, new Order { OrderId = 8, OrderNumber = "ORD-0008", CustomerId = 2, OrderDate = DateTime.Today.AddDays(-5), TotalAmount = 2500m, OrderStatus = OrderStatus.Cancelled }, new Order { OrderId = 9, OrderNumber = "ORD-0009", CustomerId = 1, OrderDate = DateTime.Today.AddDays(-5), TotalAmount = 1700m, OrderStatus = OrderStatus.Pending }, new Order { OrderId = 10, OrderNumber = "ORD-0010", CustomerId = 2, OrderDate = DateTime.Today.AddDays(-5), TotalAmount = 1400m, OrderStatus = OrderStatus.Completed }, new Order { OrderId = 11, OrderNumber = "ORD-0011", CustomerId = 1, OrderDate = DateTime.Today.AddDays(-25), TotalAmount = 2000m, OrderStatus = OrderStatus.Completed }, new Order { OrderId = 12, OrderNumber = "ORD-0012", CustomerId = 2, OrderDate = DateTime.Today.AddDays(-25), TotalAmount = 2100m, OrderStatus = OrderStatus.Pending }, new Order { OrderId = 13, OrderNumber = "ORD-0013", CustomerId = 1, OrderDate = DateTime.Today.AddDays(-25), TotalAmount = 1300m, OrderStatus = OrderStatus.Completed }, new Order { OrderId = 14, OrderNumber = "ORD-0014", CustomerId = 2, OrderDate = DateTime.Today.AddDays(-35), TotalAmount = 1900m, OrderStatus = OrderStatus.Cancelled }, new Order { OrderId = 15, OrderNumber = "ORD-0015", CustomerId = 1, OrderDate = DateTime.Today.AddDays(-35), TotalAmount = 1500m, OrderStatus = OrderStatus.Completed }, new Order { OrderId = 16, OrderNumber = "ORD-0016", CustomerId = 1, OrderDate = DateTime.Today, TotalAmount = 300m, OrderStatus = OrderStatus.Pending }, new Order { OrderId = 17, OrderNumber = "ORD-0017", CustomerId = 2, OrderDate = DateTime.Today, TotalAmount = 800m, OrderStatus = OrderStatus.Completed }, new Order { OrderId = 18, OrderNumber = "ORD-0018", CustomerId = 1, OrderDate = DateTime.Today, TotalAmount = 150m, OrderStatus = OrderStatus.Cancelled } ); // SEED ORDER ITEMS modelBuilder.Entity<OrderItem>().HasData( new OrderItem { OrderItemId = 1, OrderId = 1, ProductId = 1, Quantity = 1, UnitPrice = 1200m }, new OrderItem { OrderItemId = 2, OrderId = 1, ProductId = 3, Quantity = 1, UnitPrice = 100m }, new OrderItem { OrderItemId = 3, OrderId = 2, ProductId = 1, Quantity = 1, UnitPrice = 1200m }, new OrderItem { OrderItemId = 4, OrderId = 2, ProductId = 2, Quantity = 1, UnitPrice = 1000m }, new OrderItem { OrderItemId = 5, OrderId = 3, ProductId = 3, Quantity = 5, UnitPrice = 100m }, new OrderItem { OrderItemId = 6, OrderId = 4, ProductId = 2, Quantity = 2, UnitPrice = 800m }, new OrderItem { OrderItemId = 7, OrderId = 5, ProductId = 3, Quantity = 3, UnitPrice = 300m }, new OrderItem { OrderItemId = 8, OrderId = 6, ProductId = 1, Quantity = 1, UnitPrice = 1200m }, new OrderItem { OrderItemId = 9, OrderId = 6, ProductId = 3, Quantity = 1, UnitPrice = 600m }, new OrderItem { OrderItemId = 10, OrderId = 7, ProductId = 2, Quantity = 1, UnitPrice = 1100m }, new OrderItem { OrderItemId = 11, OrderId = 8, ProductId = 1, Quantity = 1, UnitPrice = 2500m }, new OrderItem { OrderItemId = 12, OrderId = 9, ProductId = 3, Quantity = 2, UnitPrice = 850m }, new OrderItem { OrderItemId = 13, OrderId = 10, ProductId = 2, Quantity = 1, UnitPrice = 1400m }, new OrderItem { OrderItemId = 14, OrderId = 11, ProductId = 1, Quantity = 1, UnitPrice = 2000m }, new OrderItem { OrderItemId = 15, OrderId = 12, ProductId = 2, Quantity = 1, UnitPrice = 2100m }, new OrderItem { OrderItemId = 16, OrderId = 13, ProductId = 3, Quantity = 1, UnitPrice = 1300m }, new OrderItem { OrderItemId = 17, OrderId = 14, ProductId = 1, Quantity = 1, UnitPrice = 1900m }, new OrderItem { OrderItemId = 18, OrderId = 15, ProductId = 2, Quantity = 1, UnitPrice = 1500m }, new OrderItem { OrderItemId = 19, OrderId = 16, ProductId = 3, Quantity = 3, UnitPrice = 100m }, new OrderItem { OrderItemId = 20, OrderId = 17, ProductId = 2, Quantity = 1, UnitPrice = 800m }, new OrderItem { OrderItemId = 21, OrderId = 18, ProductId = 3, Quantity = 1, UnitPrice = 150m } ); } public DbSet<Customer> Customers { get; set; } public DbSet<Order> Orders { get; set; } public DbSet<OrderItem> OrderItems { get; set; } public DbSet<Product> Products { get; set; } } }
Sales Controller
Controllers are the components that handle incoming HTTP requests, perform processing (such as querying the database, applying business logic, and filtering data), and then pass the appropriate data to the views for rendering. In our application, the SalesController handles:
- Displaying the sales report (Index action) with filtering, summary, and chart data.
- Exporting the sales report as a PDF (ExportToPdf action) using Rotativa.
- Providing an AJAX endpoint (GetOrderProducts action) that returns partial views for order product details.
- A helper method (SetDatesFromOption) to determine date ranges based on a selected option.
So, create an empty MVC Controller named SalesController within the Controllers folder and then copy and paste the following code:
using ECommerceSalesApp.Data; using ECommerceSalesApp.Models; using ECommerceSalesApp.ViewModels; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Rotativa.AspNetCore; namespace ECommerceSalesApp.Controllers { public class SalesController : Controller { private readonly ECommerceDBContext _context; // Number of orders to display per page private const int PageSize = 5; public SalesController(ECommerceDBContext context) { _context = context; } [HttpGet] public async Task<IActionResult> Index(string dateRangeOption, string category, string orderStatus, int page = 1) { // Default to "This Month" if no date range option is provided. if (string.IsNullOrEmpty(dateRangeOption)) dateRangeOption = "This Month"; // Determine the start and end dates based on the selected date range option. SetDatesFromOption(dateRangeOption, out DateTime startDate, out DateTime endDate); // Build the query for orders including related Customer, OrderItems, and Product data. var ordersQuery = _context.Orders .AsNoTracking() .Include(o => o.Customer) .Include(o => o.OrderItems) .ThenInclude(oi => oi.Product) .Where(o => o.OrderDate >= startDate && o.OrderDate <= endDate); // Filter by order status if provided. if (!string.IsNullOrEmpty(orderStatus) && Enum.TryParse<OrderStatus>(orderStatus, out var status)) { ordersQuery = ordersQuery.Where(o => o.OrderStatus == status); } // Filter by product category if provided. if (!string.IsNullOrEmpty(category)) { ordersQuery = ordersQuery.Where(o => o.OrderItems.Any(oi => oi.Product.Category == category)); } // Execute query and load orders into memory. var allOrders = await ordersQuery.ToListAsync(); // Calculate summary statistics. int totalOrders = allOrders.Count; int totalPending = allOrders.Count(o => o.OrderStatus == OrderStatus.Pending); int totalCompleted = allOrders.Count(o => o.OrderStatus == OrderStatus.Completed); int totalCanceled = allOrders.Count(o => o.OrderStatus == OrderStatus.Cancelled); decimal totalSales = allOrders.Sum(o => o.TotalAmount); // --------------------------------------------------------------------- // BAR CHART DATA PREPARATION SECTION // --------------------------------------------------------------------- // Group orders by day (using OrderDate.Date) to get daily aggregates. var dayGroups = allOrders .GroupBy(o => o.OrderDate.Date) .Select(g => new { Day = g.Key, Pending = g.Count(x => x.OrderStatus == OrderStatus.Pending), Completed = g.Count(x => x.OrderStatus == OrderStatus.Completed), Cancelled = g.Count(x => x.OrderStatus == OrderStatus.Cancelled), TotalSales = g.Sum(x => x.TotalAmount) }) .OrderBy(x => x.Day) .ToList(); // Create a list of formatted date labels (e.g., "2025-02-15") for the X-axis. var labels = dayGroups.Select(dg => dg.Day.ToString("yyyy-MM-dd")).ToList(); // Prepare datasets for the stacked bar chart (order counts by status). var datasetPending = dayGroups.Select(dg => (double)dg.Pending).ToList(); var datasetCompleted = dayGroups.Select(dg => (double)dg.Completed).ToList(); var datasetCancelled = dayGroups.Select(dg => (double)dg.Cancelled).ToList(); // Prepare dataset for the line chart (total sales per day). var datasetSales = dayGroups.Select(dg => (double)dg.TotalSales).ToList(); // Build the complete chart data object containing: // - labels: the dates (x-axis) // - datasets: an array with 3 bar datasets (stacked) and 1 line dataset. var chartData = new { labels, datasets = new object[] { // Bar dataset for Pending orders new { label = "Pending", backgroundColor = "#17a2b8", data = datasetPending, stack = "Orders" }, // Bar dataset for Cancelled orders new { label = "Cancelled", backgroundColor = "#dc3545", data = datasetCancelled, stack = "Orders" }, // Bar dataset for Completed orders new { label = "Completed", backgroundColor = "#28a745", data = datasetCompleted, stack = "Orders" }, // Line dataset for Total Sales new { label = "Total Sales ($)", type = "line", borderColor = "#ffc107", backgroundColor = "#ffc107", data = datasetSales, fill = false, yAxisID = "ySales" } } }; // Serialize the chart data object into JSON format for use in JavaScript (Chart.js). var chartDataJson = System.Text.Json.JsonSerializer.Serialize(chartData); // --------------------------------------------------------------------- // PAGINATION: Calculate and apply paging on orders list. // --------------------------------------------------------------------- int totalPages = (int)Math.Ceiling(totalOrders / (double)PageSize); var pagedOrders = allOrders .OrderBy(o => o.OrderDate) .Skip((page - 1) * PageSize) .Take(PageSize) .ToList(); // Prepare the filter view model with available filter options. var filter = new SalesReportFilterViewModel { DateRangeOption = dateRangeOption, Category = category, OrderStatus = orderStatus, Categories = await _context.Products.Select(p => p.Category).Distinct().ToListAsync(), OrderStatuses = Enum.GetNames(typeof(OrderStatus)).ToList() }; // Assemble the main view model with summary data, paged orders, and chart JSON. var vm = new SalesReportViewModel { Filter = filter, TotalOrders = totalOrders, TotalPending = totalPending, TotalCompleted = totalCompleted, TotalCanceled = totalCanceled, TotalSales = totalSales, Orders = pagedOrders, CurrentPage = page, TotalPages = totalPages, ChartDataJson = chartDataJson }; // Return the view with the complete view model. return View(vm); } // GET: /Sales/ExportToPdf [HttpGet] public async Task<IActionResult> ExportToPdf(string dateRangeOption, string category, string orderStatus) { // Default to "This Month" if no date range option is provided. if (string.IsNullOrEmpty(dateRangeOption)) dateRangeOption = "This Month"; // Determine the start and end dates for the report based on the selected date range option. SetDatesFromOption(dateRangeOption, out DateTime startDate, out DateTime endDate); // Build the query for orders with related data based on the selected filters. var ordersQuery = _context.Orders .AsNoTracking() .Include(o => o.Customer) .Include(o => o.OrderItems).ThenInclude(oi => oi.Product) .Where(o => o.OrderDate >= startDate && o.OrderDate <= endDate); // Filter by order status if provided. if (!string.IsNullOrEmpty(orderStatus) && Enum.TryParse<OrderStatus>(orderStatus, out var status)) { ordersQuery = ordersQuery.Where(o => o.OrderStatus == status); } // Filter by category if provided. if (!string.IsNullOrEmpty(category)) { ordersQuery = ordersQuery.Where(o => o.OrderItems.Any(oi => oi.Product.Category == category)); } // Retrieve the filtered orders sorted by date. var allOrders = await ordersQuery.OrderBy(o => o.OrderDate).ToListAsync(); // Calculate summary statistics for the PDF report. int totalOrders = allOrders.Count; int totalPending = allOrders.Count(o => o.OrderStatus == OrderStatus.Pending); int totalCompleted = allOrders.Count(o => o.OrderStatus == OrderStatus.Completed); int totalCanceled = allOrders.Count(o => o.OrderStatus == OrderStatus.Cancelled); decimal totalSales = allOrders.Sum(o => o.TotalAmount); // Prepare the filter view model for the PDF report. var filter = new SalesReportFilterViewModel { DateRangeOption = dateRangeOption, StartDate = startDate, EndDate = endDate, Category = category, OrderStatus = orderStatus }; // Assemble the view model for the PDF report. var vm = new SalesReportViewModel { Filter = filter, TotalOrders = totalOrders, TotalPending = totalPending, TotalCompleted = totalCompleted, TotalCanceled = totalCanceled, TotalSales = totalSales, Orders = allOrders // In the PDF, show all orders (no paging) }; // Return the PDF result using Rotativa: // - "SalesReportPdf" is the view that will be rendered as PDF. // - FileName, PageSize, and PageOrientation are configured for proper formatting. return new ViewAsPdf("SalesReportPdf", vm) { FileName = $"SalesReport_{startDate:yyyyMMdd}_{endDate:yyyyMMdd}.pdf", PageSize = Rotativa.AspNetCore.Options.Size.A4, PageOrientation = Rotativa.AspNetCore.Options.Orientation.Landscape }; } // AJAX endpoint to return product details for a given order. [HttpGet] public IActionResult GetOrderProducts(int orderId) { // Retrieve order items along with their product details. var items = _context.OrderItems .Include(oi => oi.Product) .Where(oi => oi.OrderId == orderId) .ToList(); // Return the partial view that renders product details. return PartialView("_OrderProductsPartial", items); } // Helper method to set the start and end dates based on a selected date range option. private void SetDatesFromOption(string dateRangeOption, out DateTime startDate, out DateTime endDate) { // If "All" is selected, return the widest possible date range. if (string.Equals(dateRangeOption, "All", StringComparison.OrdinalIgnoreCase)) { startDate = DateTime.MinValue; endDate = DateTime.MaxValue; return; } // By default, use today's date as the end date. endDate = DateTime.Today; switch (dateRangeOption) { case "Today": startDate = DateTime.Today; endDate = DateTime.Today; break; case "Yesterday": startDate = DateTime.Today.AddDays(-1); endDate = DateTime.Today.AddDays(-1); break; case "Last 7 Days": startDate = DateTime.Today.AddDays(-6); break; case "This Month": startDate = new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1); break; case "Last Month": var lastMonth = DateTime.Today.AddMonths(-1); startDate = new DateTime(lastMonth.Year, lastMonth.Month, 1); //1st January, 2025 endDate = startDate.AddMonths(1).AddDays(-1); //31st january break; case "Last 3 Months": startDate = DateTime.Today.AddMonths(-3); break; case "Last 6 Months": startDate = DateTime.Today.AddMonths(-6); break; case "Last 1 Year": startDate = DateTime.Today.AddYears(-1); break; default: startDate = new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1); break; } } } }
Creating Views
Views are the presentation layer of an MVC application. They contain HTML mixed with Razor syntax to display data from the controller dynamically. Views define the layout and UI for the user interface.
Index View
The main view that displays the sales report includes:
- Filter options (date range, category, order status).
- Summary information and charts.
- A paginated list of orders with a button to view order details in a modal.
- JavaScript will handle chart rendering, and AJAX calls to load product details.
So, create a view named Index.cshtml within the Views/Sales directory and then copy and paste the following code.
@model ECommerceSalesApp.Models.SalesReportViewModel @{ // Set the page title for the browser tab ViewData["Title"] = "Sales Report"; } <h1 class="mt-4 mb-4">Sales Report</h1> <!-- -------------------------------------------------------- Filter Section: Allows users to filter orders by Date Range, Category, and Order Status. -------------------------------------------------------- --> <div class="card mb-4 shadow-sm"> <div class="card-header bg-info text-white"> <h5 class="mb-0"> <i class="fas fa-filter"></i> Filter Options </h5> </div> <div class="card-body"> <!-- Form submits to the Index action (GET) --> <form method="get" asp-action="Index"> <div class="form-row"> <!-- Date Range Filter --> <div class="form-group col-md-4"> <label for="dateRangeOption">Date Range</label> <select id="dateRangeOption" name="dateRangeOption" class="custom-select"> @foreach (var option in Model.Filter.DateRangeOptions) { // If the option matches the selected filter, mark it as selected. <option value="@option" selected="@(option == Model.Filter.DateRangeOption ? "selected" : null)"> @option </option> } </select> </div> <!-- Category Filter --> <div class="form-group col-md-4"> <label for="category">Category</label> <select id="category" name="category" class="custom-select"> <option value="">All</option> @foreach (var cat in Model.Filter.Categories) { // Mark category as selected if it matches the current filter. <option value="@cat" selected="@(cat == Model.Filter.Category ? "selected" : null)"> @cat </option> } </select> </div> <!-- Order Status Filter --> <div class="form-group col-md-4"> <label for="orderStatus">Order Status</label> <select id="orderStatus" name="orderStatus" class="custom-select"> <option value="">All</option> @foreach (var status in Model.Filter.OrderStatuses) { // Mark order status as selected if it matches the current filter. <option value="@status" selected="@(status == Model.Filter.OrderStatus ? "selected" : null)"> @status </option> } </select> </div> </div> <!-- Submit button to apply filters --> <div class="form-row mt-3"> <div class="col text-right"> <button type="submit" class="btn btn-success"> <i class="fas fa-search"></i> Apply Filters </button> </div> </div> </form> </div> </div> <!-- -------------------------------------------------------- Summary Section: Displays summary data and a day-wise bar chart with a total sales line overlay. -------------------------------------------------------- --> <div class="card mb-4 shadow-sm"> <div class="card-header bg-primary text-white"> <h5 class="mb-0"> <i class="fas fa-chart-line"></i> Summary </h5> </div> <div class="card-body"> <!-- Summary Cards: Display totals for orders, statuses, and sales --> <div class="row text-center"> <div class="col-md-2 mb-3"> <div class="bg-light p-3 rounded"> <small>Total Orders</small> <h4 class="mb-0">@Model.TotalOrders</h4> </div> </div> <div class="col-md-2 mb-3"> <div class="bg-light p-3 rounded"> <small>Total Pending</small> <h4 class="mb-0">@Model.TotalPending</h4> </div> </div> <div class="col-md-2 mb-3"> <div class="bg-light p-3 rounded"> <small>Total Completed</small> <h4 class="mb-0">@Model.TotalCompleted</h4> </div> </div> <div class="col-md-2 mb-3"> <div class="bg-light p-3 rounded"> <small>Total Cancelled</small> <h4 class="mb-0">@Model.TotalCanceled</h4> </div> </div> <div class="col-md-4 mb-3"> <div class="bg-light p-3 rounded"> <small>Total Sales Amount</small> <h4 class="mb-0">$@Model.TotalSales</h4> </div> </div> </div> <hr /> <!-- Canvas element where the Chart.js bar chart will be rendered --> <div> <canvas id="dayWiseChart" width="1000" height="400"></canvas> </div> </div> </div> <!-- -------------------------------------------------------- Order Details Section: Displays a paginated table of order details and a button to export the report as a PDF. -------------------------------------------------------- --> <div class="card mb-4 shadow-sm"> <div class="card-header" style="background-color:#f8f9fa;"> <strong>Order Details</strong> <!-- Link to export the current view as a PDF --> <a class="btn btn-success btn-sm float-right" asp-action="ExportToPdf" asp-route-dateRangeOption="@Model.Filter.DateRangeOption" asp-route-category="@Model.Filter.Category" asp-route-orderStatus="@Model.Filter.OrderStatus"> Export to PDF </a> </div> <div class="card-body"> <!-- Responsive table for order details --> <div class="table-responsive"> <table class="table table-striped table-bordered"> <thead class="thead-dark"> <tr> <th>Order Number</th> <th>Order Date</th> <th>Status</th> <th>Total Amount</th> <th>Action</th> </tr> </thead> <tbody> @foreach (var order in Model.Orders) { <tr> <td>@order.OrderNumber</td> <td>@order.OrderDate.ToShortDateString()</td> <td>@order.OrderStatus</td> <td>$@order.TotalAmount</td> <td> <!-- Button to view order product details in a modal --> <button class="btn btn-info btn-sm" data-toggle="modal" data-target="#productsModal" onclick="loadOrderProducts(@order.OrderId)"> <i class="fas fa-eye"></i> View Products </button> </td> </tr> } </tbody> </table> </div> <!-- -------------------------------------------------------- Pagination Controls: Allows navigation between pages of orders. -------------------------------------------------------- --> <nav> <ul class="pagination justify-content-center"> <!-- First page button --> <li class="page-item @(Model.CurrentPage == 1 ? "disabled" : "")"> <a class="page-link" asp-action="Index" asp-route-page="1" asp-route-dateRangeOption="@Model.Filter.DateRangeOption" asp-route-category="@Model.Filter.Category" asp-route-orderStatus="@Model.Filter.OrderStatus"> First </a> </li> <!-- Previous page button --> <li class="page-item @(Model.CurrentPage == 1 ? "disabled" : "")"> <a class="page-link" asp-action="Index" asp-route-page="@(Model.CurrentPage - 1)" asp-route-dateRangeOption="@Model.Filter.DateRangeOption" asp-route-category="@Model.Filter.Category" asp-route-orderStatus="@Model.Filter.OrderStatus"> Previous </a> </li> <!-- Current page display --> <li class="page-item active"> <span class="page-link">@Model.CurrentPage</span> </li> <!-- Next page button --> <li class="page-item @(Model.CurrentPage == Model.TotalPages ? "disabled" : "")"> <a class="page-link" asp-action="Index" asp-route-page="@(Model.CurrentPage + 1)" asp-route-dateRangeOption="@Model.Filter.DateRangeOption" asp-route-category="@Model.Filter.Category" asp-route-orderStatus="@Model.Filter.OrderStatus"> Next </a> </li> <!-- Last page button --> <li class="page-item @(Model.CurrentPage == Model.TotalPages ? "disabled" : "")"> <a class="page-link" asp-action="Index" asp-route-page="@(Model.TotalPages)" asp-route-dateRangeOption="@Model.Filter.DateRangeOption" asp-route-category="@Model.Filter.Category" asp-route-orderStatus="@Model.Filter.OrderStatus"> Last </a> </li> </ul> </nav> </div> </div> <!-- -------------------------------------------------------- Modal for Product Details: Displays the list of products in an order, loaded via AJAX. -------------------------------------------------------- --> <div class="modal fade" id="productsModal" tabindex="-1" role="dialog" aria-labelledby="productsModalLabel" aria-hidden="true"> <div class="modal-dialog modal-lg" role="document"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title">Order Products</h5> <!-- Close button for the modal --> <button type="button" class="close" data-dismiss="modal" aria-label="Close"> <span aria-hidden="true">×</span> </button> </div> <!-- Modal body where AJAX-loaded product details will be inserted --> <div class="modal-body" id="productsModalBody"> <!-- Content loaded via AJAX --> </div> </div> </div> </div> @section Scripts { <!-- Include Chart.js from CDN for rendering the bar chart --> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <script> // Retrieve the serialized chart data (JSON) passed from the controller. // The Model.ChartDataJson property contains a JSON string representing an object // with properties like 'labels' (the X-axis labels) and 'datasets' (the data series). var chartData = @Html.Raw(Model.ChartDataJson); // Get the drawing context of the canvas where the chart will be rendered. // 'document.getElementById' selects the HTML canvas element with the id "dayWiseChart". // 'getContext('2d')' returns the drawing context for a 2D rendering of the canvas. var ctx = document.getElementById('dayWiseChart').getContext('2d'); // Initialize a new Chart instance using Chart.js. // This creates a new chart on the canvas using the context 'ctx'. var dayWiseChart = new Chart(ctx, { // 'type' defines the type of chart to render. Here it's a 'bar' chart. type: 'bar', // 'data' is an object that includes: // - 'labels': an array of labels for the X-axis. In this case, these are dates. // - 'datasets': an array of dataset objects, where each dataset represents a data series. data: { labels: chartData.labels, // Use the labels (e.g., dates) from the JSON data. datasets: chartData.datasets // Use the datasets array from the JSON data. }, // 'options' configures the chart's appearance and behavior. options: { // Makes the chart responsive to resizing of the window or container. responsive: true, // interaction settings control how the chart responds to user actions like hovering. interaction: { mode: 'index', // When hovering, the tooltip shows data for all datasets at the hovered index. intersect: false // The tooltip will display even if the pointer does not intersect any element. }, // Plugin options (e.g., tooltips) can be customized here. plugins: { tooltip: { mode: 'index', // Same as interaction mode; groups all tooltips for the same index. intersect: false } }, // Define the axes for the chart. scales: { // The X-axis: display the labels, and stack bar segments (if there are multiple series). x: { stacked: true // This stacks the bars on top of each other for each label. }, // Define the left Y-axis ('yCounts') for the order count values. yCounts: { type: 'linear', // A linear scale for numerical data. position: 'left', // Position this axis on the left side. stacked: true, // Stack the values if there are multiple bar datasets. beginAtZero: true, // The scale starts at zero. title: { display: true, text: 'Order Count' // Label for the Y-axis. } }, // Define the right Y-axis ('ySales') for the sales amount values. ySales: { type: 'linear', // A linear scale for numerical data. position: 'right', // Position this axis on the right side. beginAtZero: true, // The scale starts at zero. stacked: false, // Do not stack the line chart data on top of the bars. grid: { drawOnChartArea: false // Avoid drawing grid lines for this axis to reduce visual clutter. }, title: { display: true, text: 'Sales ($)' // Label for the Y-axis. } } } } }); // Function to load order products via AJAX when a "View Products" button is clicked. function loadOrderProducts(orderId) { // Construct the URL for the AJAX request. var url = '@Url.Action("GetOrderProducts", "Sales")' + '?orderId=' + orderId; // Use the Fetch API to get the HTML content for the products modal. fetch(url) .then(response => response.text()) .then(html => { // Insert the returned HTML into the modal body. document.getElementById('productsModalBody').innerHTML = html; }); } </script> }
SalesReportPdf View
Create a view named SalesReportPdf.cshtml within the Views/Sales directory, then copy and paste the following code. This is a dedicated view for generating the PDF version of the sales report. It has no layout, so the PDF output is clean and formatted for printing.
@model ECommerceSalesApp.Models.SalesReportViewModel @{ Layout = null; // No layout is used for the PDF output } <!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <title>Sales Report PDF</title> <!-- Bootstrap CSS from CDN --> <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet" /> <!-- Additional print styles to control page breaks and avoid blank spaces --> <style> /* Prevent table rows from breaking across pages */ tr { page-break-inside: avoid; } /* Keep table headers together on each page */ thead { display: table-header-group; } /* Optional: Remove any forced margins/padding that may cause blank space */ .no-page-break { page-break-inside: avoid; } /* Remove top border from repeated thead on subsequent pages */ thead tr, thead th { border-top: none !important; } </style> </head> <body class="bg-light"> <div class="container my-4 bg-white p-4 shadow-sm"> <!-- Header Section --> <div class="text-center border-bottom pb-3 mb-4"> <h1 class="text-primary">Sales Report</h1> <p class="text-muted">Confidential Report – Internal Use Only</p> </div> <!-- Company Details Section --> <div class="text-center mb-4"> <h4 class="font-weight-bold">ABC Corporation</h4> <p> 123 Main Street, Suite 456, Anytown, Anycountry 12345<br /> Phone: (123) 456-7890 | Email: info@abccorp.com </p> </div> <!-- Report Conditions Section --> <div class="border rounded p-3 mb-4 bg-light"> <h5 class="font-weight-bold">Report Generated Based On:</h5> <p> <strong>Date Range:</strong> @if (Model.Filter.DateRangeOption == "All") { @:All Data } else { @($"{Model.Filter.StartDate?.ToString("yyyy-MM-dd")} to {Model.Filter.EndDate?.ToString("yyyy-MM-dd")}") } <br /> @if (!string.IsNullOrEmpty(Model.Filter.Category)) { <span><strong>Category:</strong> @Model.Filter.Category<br /></span> } @if (!string.IsNullOrEmpty(Model.Filter.OrderStatus)) { <span><strong>Order Status:</strong> @Model.Filter.OrderStatus</span> } </p> </div> <!-- Summary Table Section --> <div class="mb-4"> <table class="table table-bordered"> <thead class="thead-dark text-center"> <tr> <th>Total Orders</th> <th>Total Pending</th> <th>Total Completed</th> <th>Total Cancelled</th> <th>Total Sales Amount</th> </tr> </thead> <tbody class="text-center"> <tr> <td>@Model.TotalOrders</td> <td>@Model.TotalPending</td> <td>@Model.TotalCompleted</td> <td>@Model.TotalCanceled</td> <td>$@Model.TotalSales</td> </tr> </tbody> </table> <p class="text-center mt-3"> This summary provides a comprehensive overview of our sales performance based on the selected criteria. </p> </div> <!-- Order Details Section --> <div class="mb-4"> <h5 class="font-weight-bold">Order Details</h5> <div class="table-responsive"> <table class="table table-striped table-bordered"> <thead class="thead-dark text-center"> <tr> <th>Order Number</th> <th>Order Date</th> <th>Status</th> <th>Total Amount</th> </tr> </thead> <tbody class="text-center"> @foreach (var order in Model.Orders) { <tr> <td>@order.OrderNumber</td> <td>@order.OrderDate.ToShortDateString()</td> <td>@order.OrderStatus</td> <td>$@order.TotalAmount</td> </tr> } </tbody> </table> </div> </div> <!-- Footer Section --> <div class="text-center border-top pt-2"> <small>Report generated on @DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")</small> </div> </div> </body> </html>
Partial View for Product Details Modal
Create a file _OrderProductsPartial.cshtml in the Views/Sales folder. This is a partial view that renders a table of products within an order. When the user wants to see order product details, this view is loaded dynamically into a modal via AJAX.
@model List<ECommerceSalesApp.Models.OrderItem> <table class="table table-sm table-bordered"> <thead class="thead-light"> <tr> <th>Product</th> <th>Quantity</th> <th>Unit Price</th> <th>Line Total</th> </tr> </thead> <tbody> @foreach (var item in Model) { var lineTotal = item.UnitPrice * item.Quantity; <tr> <td>@item.Product?.Name</td> <td>@item.Quantity</td> <td>$@item.UnitPrice</td> <td>$@lineTotal</td> </tr> } </tbody> </table>
Modifying _Layout.cshtml
Modify the _Layout.cshtml view as follows. The _Layout View defines the common layout for all pages, including header, navigation, footer, and site-wide styles or scripts.
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>@ViewData["Title"] - ECommerceSalesApp</title> <!-- Using Bootstrap 4.5.2 from CDN --> <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet" /> <!-- Site-specific styles --> <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" /> <link rel="stylesheet" href="~/ECommerceSalesApp.styles.css" asp-append-version="true" /> </head> <body> <header> <nav class="navbar navbar-expand-sm navbar-light bg-white border-bottom box-shadow mb-3"> <div class="container-fluid"> <a class="navbar-brand" asp-area="" asp-controller="Home" asp-action="Index">ECommerceSalesApp</a> <button class="navbar-toggler" type="button" data-toggle="collapse" data-target=".navbar-collapse" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation"> <span class="navbar-toggler-icon"></span> </button> <div class="navbar-collapse collapse d-sm-inline-flex justify-content-between"> <ul class="navbar-nav flex-grow-1"> <li class="nav-item"> <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">Home</a> </li> <li class="nav-item"> <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a> </li> </ul> </div> </div> </nav> </header> <div class="container"> <main role="main" class="pb-3"> @RenderBody() </main> </div> <!-- Footer appears after the main content --> <footer class="border-top footer text-muted mt-3"> <div class="container"> © 2025 - ECommerceSalesApp - <a asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a> </div> </footer> <!-- jQuery, Popper.js, and Bootstrap Bundle (includes Popper) --> <script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.1/dist/umd/popper.min.js"></script> <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.bundle.min.js"></script> <script src="~/js/site.js" asp-append-version="true"></script> @await RenderSectionAsync("Scripts", required: false) </body> </html>
Download wkhtmltopdf:
We need to download wkhtmltopdf, which is the underlying tool used by Rotativa to generate PDF from HTML or Views. You can download it from the official wkhtmltopdf website. So, please visit to the following website:
https://wkhtmltopdf.org/downloads.html
This will open the following page. Choose the correct version for your operating system.
Once you download the exe, then install it on your machine. After installing wkhtmltopdf, note the installation path. In my machine, it is in the below location C:\Program Files\wkhtmltopdf\bin:
Modifying AppSettings.json File:
Please modify the appsettings.json file as follows. The appsettings.json file is a configuration file used to store various application settings. These might include connection strings, logging configuration, and settings for third-party libraries.
{ "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, "AllowedHosts": "*", "ConnectionStrings": { "DefaultConnection": "Server=LAPTOP-6P5NK25R\\SQLSERVER2022DEV;Database=ECommerceDB;Trusted_Connection=True;TrustServerCertificate=True;" }, "Rotativa": { "WkhtmltopdfPath": "C:\\Program Files\\wkhtmltopdf\\bin" } }
Key settings in appsettings.json:
- ConnectionStrings: Contains the database connection string for SQL Server.
- Logging: Configures the logging levels for the application.
- Rotativa: Contains the configuration for the wkhtmltopdf tool’s installation path, which is used by Rotativa to generate PDFs.
Modifying Program Class File:
Please modify the Program.cs class file as follows. The Program.cs file is the entry point of an ASP.NET Core application. It configures services, middleware, and application settings before running the app.
using ECommerceSalesApp.Data; using Microsoft.EntityFrameworkCore; using Rotativa.AspNetCore; namespace ECommerceSalesApp { public class Program { public static void Main(string[] args) { var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddControllersWithViews(); // DbContext builder.Services.AddDbContext<ECommerceDBContext>(options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")) ); var app = builder.Build(); // Configure Rotativa using the path from appsettings.json var wkhtmlPath = builder.Configuration["Rotativa:WkhtmltopdfPath"]; RotativaConfiguration.Setup(app.Environment.WebRootPath, wkhtmlPath); // Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler("/Home/Error"); // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. app.UseHsts(); } app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseRouting(); app.UseAuthorization(); app.MapControllerRoute( name: "default", pattern: "{controller=Sales}/{action=Index}/{id?}"); app.Run(); } } }
Key Tasks of Program.cs Class File:
In our application, it performs several key tasks:
- Configures services: Adds MVC services and sets up Entity Framework Core with SQL Server using the connection string from the configuration.
- Sets up Rotativa: Reads the path for wkhtmltopdf from the configuration (appsettings.json) and configures Rotativa.
- Builds the middleware pipeline: Configures exception handling, HTTPS redirection, static files, routing, and authorization.
- Defines the default route: Sets the default controller to Sales and the action to Index.
Migrate and Create the Database
Open the Visual Studio Package Manager Console and run the following commands to create the database and tables:
- Add-Migration Mig1
- Update-Database
This will create the InvoiceDB database with the required tables, as shown in the below image:
Now, run the application and test the functionalities, and it should work as expected. This application is an e-commerce sales management system that generates reports on sales performance. It includes features like filtering sales by date, category, and status. Users can view summarized data in a web interface with visual charts and export the detailed report as a PDF document.
In the next article, I will discuss the ASP.NET Core MVC Request Life Cycle with examples. In this article, I explain How to Work with Bar Charts and PDFs in ASP.NET Core MVC Applications with Examples. I hope you enjoy this article on Working with Bar Charts and PDFs in ASP.NET Core MVC.