Part 3: Printing Full Dynamic Layout Content (Reports, Data Grids, etc…)

This is part of the series of introducing Vanilla ASP.NET Web Forms Architecture. Read more about this at the main menu: Main Article: [Introducing Vanilla ASP.NET Web Forms Architecture]

Part 3: Printing Full Dynamic Layout Content (Reports, Data Grids, etc…)

Introduction

In Part 1, we covered static layouts (tickets, cards) where everything is fixed. In Part 2, we tackled semi-dynamic layouts (invoices) where a fixed header/footer surrounds a variable-length item section — requiring careful height calculations and pagination logic.

Now in Part 3, we’re going full dynamic — and here’s the good news: it’s actually simpler than Part 2!

Why? Because with full dynamic reports:

  • No fixed footer to worry about — No totals section that must appear on the last page
  • No height calculations — Just count the rows and paginate
  • Simple math — 30 rows per page? Divide total rows by 30, done!

The classic examples are:

  • Daily/Monthly Sales Reports
  • Transaction Lists
  • Inventory Reports
  • Customer Lists
  • Audit Logs

These documents share a common structure:

  1. Simple Header — Report title, date, page number, summary totals
  2. Data Table — Rows and rows of data, same structure on every page
  3. No Footer — Or just a simple page number (already in header)

Visual Design – Full Dynamic Layout (Daily Sales Report)

Example: A daily sales report showing all invoices for the day

Works on: A4/Letter paper printers

Imagine we have a daily sales report that will be printed on A4 paper, which will look something like this:

The report consists of:

  • Header Section (20mm): Report title, date, summary totals, page number
  • Data Table (250mm): Invoice rows with consistent columns across all pages

Understanding the Page Layout

Here’s the simple layout for our report:

A4 Report Layout 297mm height
Top Padding 10mm
Header Section 20mm
  • Report title
  • Report date
  • Summary: Total Sales, Invoice Count
  • Page: X of Y
Report Table 250mm
  • Table header (8mm)
  • Table body (242mm)
  • ~30 rows @ ~8mm each
297mm
Padding (10mm)
Header (20mm)
Report Table (250mm)

The Simple Pagination Logic

This is where full dynamic shines — the pagination is dead simple:

// Total rows in the report
int totalRows = salesData.Count;

// Rows per page (fixed)
int rowsPerPage = 30;

// Calculate total pages (simple ceiling division)
int totalPages = (int)Math.Ceiling((double)totalRows / rowsPerPage);

// Example: 85 rows ÷ 30 = 2.83 → 3 pages
// Page 1: rows 1-30
// Page 2: rows 31-60
// Page 3: rows 61-85

No height calculations. No footer positioning. Just simple math.

Data Model

Prepare the C# Class object for the sales data:

/// <summary>
/// Represents a single row in the daily sales report
/// </summary>
public class DailySalesRow
{
    public int RowNo { get; set; }           // Sequential row number
    public string InvoiceNo { get; set; }    // Invoice number (e.g., INV-2025-0001)
    public string CustomerName { get; set; } // Customer name
    public decimal TotalAmount { get; set; } // Invoice total amount
    public decimal PaidAmount { get; set; }  // Amount paid so far
    public string PaidStatus { get; set; }   // "Paid", "Partial", "Unpaid"
}

/// <summary>
/// Container for the entire report data
/// </summary>
public class DailySalesReport
{
    public DateTime ReportDate { get; set; }       // The date of the report
    public decimal TotalSales { get; set; }        // Sum of all invoice totals
    public int TotalInvoices { get; set; }         // Count of invoices
    public List<DailySalesRow> Rows { get; set; }  // All the data rows
}

URL API Routing

Preparing the URL API Routing:

// Example of URL API format:

// Query String
// https://myweb.com/apiDailySales?date=2025-01-02&autoprint=1

// MVC alike URL
// https://myweb.com/apiDailySales/2025-01-02/1

// MVC alike URL Routing

using System.Web.Routing;

public class Global : System.Web.HttpApplication
{
    protected void Application_Start(object sender, EventArgs e)
    {
        // Route the main api page
        RouteTable.Routes.MapPageRoute("apiSales1", "apiDailySales", "~/apiDailySales.aspx");

        // Route with date parameter only (auto-print defaults to true)
        RouteTable.Routes.MapPageRoute("apiSales2", "apiDailySales/{date}", "~/apiDailySales.aspx");
        RouteTable.Routes.MapPageRoute("apiSales3", "apiDailySales/{date}/", "~/apiDailySales.aspx");

        // Route with date and auto-print parameters
        RouteTable.Routes.MapPageRoute("apiSales4", "apiDailySales/{date}/{autoprint}", "~/apiDailySales.aspx");
        RouteTable.Routes.MapPageRoute("apiSales5", "apiDailySales/{date}/{autoprint}/", "~/apiDailySales.aspx");
    }
}

Report Generator Engine

The Report Generator Engine — notice how much simpler it is compared to Part 2:

public class engineDailySales
{
    // ============================================
    // Configuration: Rows per page
    // This is the ONLY pagination setting needed!
    // ============================================
    const int ROWS_PER_PAGE = 30;

    /// <summary>
    /// Generate the Daily Sales Report HTML document
    /// </summary>
    /// <param name="reportDate">The date to generate report for</param>
    /// <param name="autoprint">1 = auto print, 0 = preview only</param>
    /// <returns>Complete HTML document as string</returns>
    public static string GenerateReport(DateTime reportDate, int autoprint)
    {
        // ============================================
        // Step 1: Get the report data from database
        // ============================================
        DailySalesReport report = Database.GetDailySalesReport(reportDate);

        // No data found for this date
        if (report == null || report.Rows == null || report.Rows.Count == 0)
        {
            return ReportNoData(reportDate);
        }

        // ============================================
        // Step 2: Calculate pagination
        // This is SO much simpler than Part 2!
        // ============================================
        int totalRows = report.Rows.Count;
        int totalPages = (int)Math.Ceiling((double)totalRows / ROWS_PER_PAGE);

        // ============================================
        // Step 3: Build the HTML document
        // ============================================
        StringBuilder sb = new StringBuilder();

        // Render the HTML head section
        sb.Append($@"
<!DOCTYPE html>
<html lang='en'>
<head>
    <meta charset='UTF-8'>
    <meta name='viewport' content='width=device-width, initial-scale=1.0'>
    <title>Daily Sales Report - {reportDate:dd MMM yyyy}</title>
    <style>
        {GetCSS()}
    </style>
</head>
<body>
        ");

        // Insert auto-print script if requested
        if (autoprint == 1)
        {
            sb.Append("<script>window.onload = ()=> { window.print(); };</script>");
        }

        // ============================================
        // Step 4: Generate each page
        // Loop through pages and slice the data
        // ============================================
        for (int pageNum = 0; pageNum < totalPages; pageNum++)
        {
            // Calculate which rows belong to this page
            // Page 0: rows 0-29, Page 1: rows 30-59, etc.
            int startIndex = pageNum * ROWS_PER_PAGE;

            // Get the rows for this page using Skip and Take
            var pageRows = report.Rows
                .Skip(startIndex)           // Skip rows from previous pages
                .Take(ROWS_PER_PAGE)        // Take only rows for this page
                .ToList();

            // Insert page break before each page (except the first)
            if (pageNum > 0)
            {
                sb.Append("<div style='page-break-after: always;'></div>");
            }

            // ============================================
            // Render the page content
            // ============================================
            sb.Append("<div class='page'>");

            // Render header (same on every page, just different page number)
            RenderHeader(sb, report, pageNum + 1, totalPages);

            // Render the data table for this page's rows
            RenderDataTable(sb, pageRows);

            sb.Append("</div>"); // Close .page
        }

        // Close the HTML document
        sb.Append("</body></html>");

        return sb.ToString();
    }

    /// <summary>
    /// Render the report header section
    /// Contains: Title, Date, Summary totals, Page number
    /// </summary>
    static void RenderHeader(StringBuilder sb, DailySalesReport report, int currentPage, int totalPages)
    {
        sb.Append($@"
    <!-- ========================================
         Report Header Section (20mm)
         ======================================== -->
    <div class='report-header'>

        <!-- Left side: Title and Date -->
        <div>
            <div class='report-title'>Daily Sales Report</div>
            <div class='report-date'>Report Date: {report.ReportDate:dd MMM yyyy}</div>
        </div>

        <!-- Right side: Summary boxes -->
        <div class='report-meta'>
            <div class='report-summary'>

                <!-- Total Sales Amount -->
                <div class='summary-box'>
                    <div class='summary-label'>Total Sales</div>
                    <div class='summary-value'>${report.TotalSales:#,##0.00}</div>
                </div>

                <!-- Total Invoice Count -->
                <div class='summary-box'>
                    <div class='summary-label'>Invoices</div>
                    <div class='summary-value'>{report.TotalInvoices}</div>
                </div>

                <!-- Page Number -->
                <div class='summary-box'>
                    <div class='summary-label'>Page</div>
                    <div class='summary-value'>{currentPage} of {totalPages}</div>
                </div>

            </div>
        </div>

    </div>
        ");
    }

    /// <summary>
    /// Render the data table for the current page
    /// </summary>
    /// <param name="sb">StringBuilder to append to</param>
    /// <param name="rows">The rows to display on this page (max 30)</param>
    static void RenderDataTable(StringBuilder sb, List<DailySalesRow> rows)
    {
        sb.Append(@"
    <!-- ========================================
         Report Table Section (250mm)
         Table Header: 8mm
         Table Body: 242mm (~30 rows)
         ======================================== -->
    <div class='report-table-container'>
        <table class='report-table'>

            <!-- Table Header -->
            <thead>
                <tr>
                    <th class='col-no'>No</th>
                    <th class='col-invoice'>Invoice No</th>
                    <th class='col-customer'>Customer Name</th>
                    <th class='col-total'>Total Amount</th>
                    <th class='col-paid'>Paid Amount</th>
                    <th class='col-status'>Status</th>
                </tr>
            </thead>

            <!-- Table Body -->
            <tbody>
        ");

        // ============================================
        // Loop through each row and render it
        // ============================================
        foreach (var row in rows)
        {
            // Determine CSS class for paid amount coloring
            // Green for fully paid, red for zero, default for partial
            string paidClass = "amount";
            if (row.PaidAmount >= row.TotalAmount)
                paidClass = "amount amount-positive";  // Green - fully paid
            else if (row.PaidAmount == 0)
                paidClass = "amount amount-zero";      // Red - nothing paid

            // Determine status badge CSS class
            string statusClass = row.PaidStatus.ToLower() switch
            {
                "paid" => "status-badge status-paid",       // Green badge
                "partial" => "status-badge status-partial", // Yellow badge
                "unpaid" => "status-badge status-unpaid",   // Red badge
                _ => "status-badge"                         // Default
            };

            // Render the table row
            sb.Append($@"
                <tr>
                    <td class='col-no'>{row.RowNo}</td>
                    <td class='col-invoice'>{row.InvoiceNo}</td>
                    <td class='col-customer'>{row.CustomerName}</td>
                    <td class='col-total amount'>{row.TotalAmount:#,##0.00}</td>
                    <td class='col-paid {paidClass}'>{row.PaidAmount:#,##0.00}</td>
                    <td class='col-status'><span class='{statusClass}'>{row.PaidStatus}</span></td>
                </tr>
            ");
        }

        // Close the table
        sb.Append(@"
            </tbody>
        </table>
    </div>
        ");
    }

    /// <summary>
    /// Generate error page when no data is found
    /// </summary>
    static string ReportNoData(DateTime reportDate)
    {
        return $@"
<!DOCTYPE html>
<html lang='en'>
<head>
    <meta charset='UTF-8'>
    <meta name='viewport' content='width=device-width, initial-scale=1.0'>
    <title>No Data Found</title>
</head>
<body>
    <script>
        window.onload = function() {{
            alert('No sales data found for {reportDate:dd MMM yyyy}');
            // Notify parent window if in iframe
            if (window.parent && window.parent !== window) {{
                window.parent.postMessage('no-data-found', '*');
            }}
        }};
    </script>
    <div style='display: none;'>No data found</div>
</body>
</html>";
    }

    /// <summary>
    /// Get the CSS styles for the report
    /// </summary>
    static string GetCSS()
    {
        return @"
        /* CSS content - see full CSS section below */
        ";
    }
}

Backend API Page

The Backend API page, create a blank ASP.NET Web Forms:

<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="apiDailySales.aspx.cs" Inherits="myweb.apiDailySales" %>

Delete all the frontend markup and leave only the first line page directive.

The code behind:

protected void Page_Load(object sender, EventArgs e)
{
    // ============================================
    // Step 1: User authentication verification
    // ============================================
    if (Session["login_user"] == null) 
    {
        // Option 1: 404 Page not found (hide the page existence)
        Response.StatusCode = 404;
        return;

        // Option 2: 401 Unauthorized (reveal that auth is required)
        // Response.StatusCode = 401;
        // return;
    }

    // ============================================
    // Step 2: Initialize default values
    // ============================================
    DateTime reportDate = DateTime.Today;  // Default to today
    int auto_print = 1;                    // Auto-print by default

    // ============================================
    // Step 3: Obtain Request Data
    // ============================================

    // Method 1: Query String & Form Post
    // Example: /apiDailySales?date=2025-01-02&autoprint=1

    // Parse the date parameter
    if (!string.IsNullOrEmpty(Request["date"]))
    {
        if (!DateTime.TryParse(Request["date"], out reportDate))
        {
            // Invalid date format - return 400 Bad Request
            Response.StatusCode = 400;
            return;
        }
    }

    // Parse the autoprint parameter
    if (Request["autoprint"] != null) 
    {
        int.TryParse(Request["autoprint"] + "", out auto_print);
    }

    // Method 2: JSON body request (for AJAX calls)
    // Uncomment if you prefer JSON body:
    /*
    string jsonBody;
    using (var reader = new StreamReader(Request.InputStream, Encoding.UTF8))
    {
        jsonBody = reader.ReadToEnd();
    }
    var reportRequest = JsonSerializer.Deserialize<ReportRequest>(jsonBody);
    reportDate = reportRequest.ReportDate;
    auto_print = reportRequest.AutoPrint;
    */

    // ============================================
    // Step 4: Generate the Report HTML document
    // ============================================
    var html = engineDailySales.GenerateReport(reportDate, auto_print);

    // Check if report generation failed
    if (html == null)
    {
        // 400 Bad Request
        Response.StatusCode = 400;
        return;
    }

    // ============================================
    // Step 5: Output the HTML to the browser
    // ============================================
    Response.Write(html);
}

Frontend Print Request

Auto-Print, No Preview Needed

<!-- Date picker for selecting report date -->
<input type="date" id="reportDate" value="2025-01-02" />

<!-- Print button -->
<button type="button" onclick="printReport();">Print Daily Sales Report</button>

<!-- Hidden iframe for printing -->
<iframe id="frameprint" src="" style="height:0; width:0; border:none; overflow:hidden;"></iframe>

<script>

    // ============================================
    // Global Configuration
    // ============================================
    const urlApi = "/apiDailySales";
    let auto_print = 1;  // Enable auto-print
    let frameprint = document.querySelector("#frameprint");

    // ============================================
    // Print function - triggers the report generation
    // ============================================
    function printReport() {
        // Get the selected date from the date picker
        let reportDate = document.querySelector("#reportDate").value;

        // Build the API URL with parameters
        let url = `${urlApi}?date=${reportDate}&autoprint=${auto_print}`;

        // Load the report into the hidden iframe
        // The report will auto-print due to the window.onload script
        frameprint.src = url;
    }

    // ============================================
    // Listen for messages from the iframe
    // (e.g., "no-data-found" error)
    // ============================================
    document.addEventListener('DOMContentLoaded', function() {
        window.addEventListener('message', handleIframeMessages);
    });

    function handleIframeMessages(event) {
        // Security: Verify the message origin
        if (event.origin !== window.location.origin) return;

        // Handle different message types
        switch(event.data) {
            case 'no-data-found':
                // Show user-friendly notification
                showNotification('No sales data found for the selected date', 'warning');
                // Clear the iframe
                document.querySelector("#frameprint").src = '';
                break;

            default:
                console.log('Unknown message from iframe:', event.data);
        }
    }

</script>

Print Preview, No Auto Print

Show the print preview in iframe:

<style>
/* ============================================
   Print Preview Overlay Styles
   ============================================ */
.div-print-preview {
    position: fixed;
    top: 20px;
    left: 20px;
    right: 20px;
    bottom: 20px;
    border: 2px solid gray;
    padding: 10px;
    display: none;           /* Hidden by default */
    z-index: 999999;         /* Always on top */
    background: white;
}

.div-print-preview-button {
    background: #35b1d4;
    color: white;
    display: inline-block;
    padding: 10px 20px;
    border: none;
    cursor: pointer;
    margin-right: 10px;
}

.div-print-preview-button:hover {
    background: #2a9cbf;
}

.div-print-preview-iframe {
    position: absolute;
    top: 60px;
    left: 10px;
    right: 10px;
    bottom: 10px;
    width: auto;
    height: auto;
    border: 1px solid gray;
}
</style>

<!-- Date picker -->
<input type="date" id="reportDate" value="2025-01-02" />

<!-- Preview button -->
<button type="button" onclick="showPrintPreview();">Print Preview</button>

<!-- Print Preview Container -->
<div id="divPrintPreview" class="div-print-preview">
    <button type="button" class="div-print-preview-button" onclick="printIframe();">Print</button>
    <button type="button" class="div-print-preview-button" onclick="closePrintPreview();">Close</button>
    <iframe id="frameprint" class="div-print-preview-iframe"></iframe>
</div>

<script>

// ============================================
// Global Configuration
// ============================================
const urlApi = "/apiDailySales";
let auto_print = 0;  // Disable auto-print for preview mode
let divPrintPreview = document.querySelector("#divPrintPreview");
let frameprint = document.querySelector("#frameprint");

// ============================================
// Show the print preview overlay
// ============================================
function showPrintPreview() {
    // Get the selected date
    let reportDate = document.querySelector("#reportDate").value;

    // Build URL (autoprint=0 for preview)
    let url = `${urlApi}?date=${reportDate}&autoprint=${auto_print}`;

    // Load report into iframe
    frameprint.src = url;

    // Show the preview container
    divPrintPreview.style.display = "block";
}

// ============================================
// Print the iframe content
// ============================================
function printIframe() {
    // Call print() on the iframe's window object
    // This prints only the iframe content, not the parent page
    frameprint.contentWindow.print();
}

// ============================================
// Close the print preview overlay
// ============================================
function closePrintPreview() {
    // Hide the preview container
    divPrintPreview.style.display = "none";

    // Clear the iframe to free memory
    frameprint.src = "";
}

</script>

The CSS for The Report

body {
	font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
	font-size: 11px;
	line-height: 1.4;
	color: #333;
	margin: 0;
	padding: 0;
}

.page {
	padding-top: 10mm;
	width: 175mm;
	margin: auto;
	background: white;
}

/* ========================================
   Header Section (20mm height)
   ======================================== */
.report-header {
	height: 20mm;
	display: flex;
	justify-content: space-between;
	align-items: center;
	border-bottom: 2px solid #2c5aa0;
	padding: 0 5mm;
	margin-bottom: 3mm;
}

.report-title {
	font-size: 18px;
	font-weight: bold;
	color: #2c5aa0;
	text-transform: uppercase;
	letter-spacing: 1px;
}

.report-date {
	font-size: 12px;
	color: #555;
}

.report-meta {
	text-align: right;
}

.report-meta-item {
	font-size: 10px;
	color: #666;
	margin-bottom: 2px;
}

.report-meta-value {
	font-weight: bold;
	color: #333;
}

.report-summary {
	display: flex;
	gap: 15mm;
	justify-content: flex-end;
}

.summary-box {
	text-align: center;
	padding: 2mm 4mm;
	background: #f8f9fa;
	border-radius: 3px;
	border: 1px solid #e0e0e0;
}

.summary-label {
	font-size: 9px;
	color: #666;
	text-transform: uppercase;
}

.summary-value {
	font-size: 14px;
	font-weight: bold;
	color: #2c5aa0;
}

/* ========================================
   Report Table Section (250mm height)
   ======================================== */
.report-table-container {
	height: 250mm;
	padding: 0 5mm;
}

.report-table {
	width: 100%;
	border-collapse: collapse;
	font-size: 10px;
}

/* Table Header (8mm height) */
.report-table thead tr {
	height: 8mm;
}
.report-table thead th {
	background: #2c5aa0;
	color: white;
	padding: 0 2mm;
	text-align: left;
	font-weight: 600;
	text-transform: uppercase;
	font-size: 9px;
	letter-spacing: 0.5px;
	border: 1px solid #1e4a8a;
}

/* Table Body */
.report-table tbody tr {
	height: 8mm;
}
.report-table tbody td {
	padding: 0 2mm;
	border-bottom: 1px solid #e0e0e0;
	border-left: 1px solid #e0e0e0;
	border-right: 1px solid #e0e0e0;
	vertical-align: middle;
}

.report-table tbody tr:nth-child(even) {
	background: #fafafa;
}

.report-table tbody tr:hover {
	background: #f0f7ff;
}

/* Column Widths */
.col-no { 
	width: 6mm; 
	text-align: center; 
}

.col-invoice { 
	width: 26mm; 
	font-family: 'Consolas', 'Courier New', monospace;
}

.col-customer { 
	width: auto; 
}

.col-total { 
	width: 20mm; 
	text-align: right; 
}

.col-paid { 
	width: 20mm; 
	text-align: right; 
}

.col-status { 
	width: 20mm; 
	text-align: center; 
}

/* Status Badges */
.status-badge {
	display: inline-block;
	padding: 1mm 3mm;
	border-radius: 3px;
	font-size: 9px;
	font-weight: 600;
	text-transform: uppercase;
}

.status-paid {
	background: #d4edda;
	color: #155724;
}

.status-partial {
	background: #fff3cd;
	color: #856404;
}

.status-unpaid {
	background: #f8d7da;
	color: #721c24;
}

/* Amount Formatting */
.amount {
	font-family: 'Consolas', 'Courier New', monospace;
}

.amount-positive {
	color: #28a745;
}

.amount-zero {
	color: #dc3545;
}

/* ============================================
   Page Settings
   - A4 portrait orientation
   - No margins (to hide browser print headers/footers)
   ============================================ */
@page {
    size: A4 portrait;
    margin: 0;

    /* Remove default browser print headers and footers */
    @top-center { content: none; }
    @bottom-center { content: none; }
}

Comparison: Part 2 vs Part 3 Pagination

Let’s compare the pagination complexity:

AspectPart 2 (Semi-Dynamic)Part 3 (Full Dynamic)
Height CalculationMust calculate each row height based on contentNot needed – fixed rows per page
Footer HandlingFooter only on last page, requires trackingNo footer to worry about
Pagination LogicComplex – accumulate heights, track available spaceSimple – totalRows / rowsPerPage
Page Break DecisionWhen accumulated height exceeds available spaceEvery N rows
Code Complexity~100 lines for pagination~10 lines for pagination

The key insight: When you don’t have a footer that must appear on the last page only, pagination becomes trivially simple!

Extending to Other Reports

The same pattern works for any data grid report:

Monthly Sales Report:

// Just change the data source and maybe adjust ROWS_PER_PAGE
const int ROWS_PER_PAGE = 25;  // Might want fewer rows for more columns
var report = Database.GetMonthlySalesReport(year, month);

Inventory Report:

// Different columns, same pattern
public class InventoryRow
{
    public string SKU { get; set; }
    public string ProductName { get; set; }
    public int QuantityOnHand { get; set; }
    public int ReorderLevel { get; set; }
    public string Status { get; set; }  // "OK", "Low", "Out of Stock"
}

Customer List:

// Even simpler - just customer data
public class CustomerRow
{
    public int RowNo { get; set; }
    public string CustomerCode { get; set; }
    public string CustomerName { get; set; }
    public string Phone { get; set; }
    public string Email { get; set; }
    public decimal TotalPurchases { get; set; }
}

Summary

In this part, we covered:

  1. Full Dynamic Layout — Simpler than semi-dynamic because there’s no footer positioning
  2. Simple Pagination — Just divide total rows by rows-per-page
  3. Data Model — Clean separation of report metadata and row data
  4. Generator Engine — Straightforward loop with Skip/Take for pagination
  5. Status Badges — Visual indicators for data states (Paid/Partial/Unpaid)
  6. Professional Styling — Clean, print-friendly CSS with alternating row colors

The key difference from Part 2 is the absence of complex footer handling. When every page has the same structure (header + data rows), pagination becomes a simple math problem.

Feature Photo by Artem Podrez:
https://www.pexels.com/photo/close-up-shot-of-a-person-holding-printed-papers-8512120/