✈️ FlightPlan Developer Guide

← Back to Guide

Filtered Dashboard Pattern

This guide explains how to build filtered dashboard pages using the pattern established in SCM (Speaker Compliance Monitoring). The pattern provides a shared filter bar, cookie persistence, automatic Plotly inclusion, and consistent page layout.

Architecture

SCMBasePage                    ← App styling, menu
└── FilteredBasePage           ← Filter bar, layout, Plotly
    └── ProgramAnalysisPage    ← Your dashboard content

Components

FilterState

Manages filter values with cookie persistence:

public class FilterState
{
    // Date range
    public DateTime StartDate { get; set; }
    public DateTime EndDate { get; set; }
    
    // Dropdown filters (empty = "All")
    public string Status { get; set; }
    public string Enterprise { get; set; }
    // ... more filters
    
    // Methods
    void LoadFromCookies(HttpRequest request);
    void LoadFromForm(HttpRequest request);
    void SaveToCookies(Action<string, string, int> setCookie);
    string BuildWhereClause(bool excludeStatus = false);
    void AddParameters(DbQuery q, bool excludeStatus = false);
}

FilteredBasePage

Abstract base that handles common filter infrastructure:

public abstract class FilteredBasePage : SCMBasePage
{
    protected FilterState Filters { get; }
    
    // You implement this:
    protected abstract void BuildPageContent(ContainerElement main);
}

What FilteredBasePage provides:

Creating a Filtered Dashboard

Step 1: Create the Page Class

using FlightPlan.Core.Dom;
using FlightPlan.Core.Dom.Elements;
using PixieDust.Charting;
using CoreCss = FlightPlan.Core.Dom.Styles.CoreStyles.Css;

namespace FlightPlan.SpkrComp.Pages
{
    public class MyDashboardPage : FilteredBasePage
    {
        protected override string RequiredRole => "read";

        protected override void BuildPageContent(ContainerElement main)
        {
            // Your content here - main is a Panel.Row().Grow()
        }
    }
}

Step 2: Implement BuildPageContent

The main parameter is already a horizontal row that grows to fill available space:

protected override void BuildPageContent(ContainerElement main)
{
    // Left panel (60%)
    var left = main.Add(Panel.Col().Percent(60));
    BuildLeftPanel(left);
    
    // Right panel (40%)
    var right = main.Add(Panel.Col().Percent(40));
    BuildRightPanel(right);
}

private void BuildLeftPanel(ContainerElement left)
{
    // KPIs at top (fixed height)
    var kpiRow = left.Add(Panel.Row().Fixed());
    // ... add KPI cards
    
    // Chart in middle (grows)
    var chartArea = left.Add(Panel.Col().Grow().Fill());
    // ... add chart
    
    // Table at bottom (fixed height)
    var tableArea = left.Add(Panel.Col().Fixed());
    // ... add table
}

Step 3: Use the Filters Property

Access filter values when querying data:

protected override void BuildPageContent(ContainerElement main)
{
    // Filters property is available from FilteredBasePage
    var data = SCMAdapter.GetMonthlyTotals(Filters);
    
    // Use in chart
    var chart = new BarChart()
        .FromData(data, "month_label", "cnt")
        .WithFill();
}

Step 4: Register the Route

In your app's routing (e.g., SCMApp.cs):

else if (internalPath.StartsWith("/MyDashboard", StringComparison.OrdinalIgnoreCase))
{
    page = new Pages.MyDashboardPage();
}

Step 5: Add Menu Item

In your base page (e.g., SCMBasePage.cs):

MenuItems.Add(new MenuItem("My Dashboard", "/SCM/MyDashboard"));

Complete Example

public class SalesDashboardPage : FilteredBasePage
{
    protected override string RequiredRole => "read";

    protected override void BuildPageContent(ContainerElement main)
    {
        // LEFT PANEL (60%)
        var left = main.Add(Panel.Col().Percent(60));

        // KPI Row
        var kpiRow = left.Add(Panel.Row().Fixed());
        kpiRow.Add(Panel.Col().Card()).Add(
            new KpiCard($"{Adapter.GetTotal(Filters)}", "Total Sales"));
        kpiRow.Add(Panel.Col().Card()).Add(
            new KpiCard($"{Adapter.GetAverage(Filters):C}", "Average"));

        // Trend Chart
        var chartArea = left.Add(Panel.Col().Grow().Fill());
        var chartCard = chartArea.Add(Panel.Col().Grow().Fill().Card());
        var trendData = Adapter.GetMonthlyTrend(Filters);
        chartCard.Add(new Div(CoreCss.ChartTitle).WithText("Sales Trend"));
        chartCard.Add(new ChartElement(
            new BarChart()
                .FromData(trendData, "month", "sales")
                .WithFill()));

        // Data Table
        var tableArea = left.Add(Panel.Col().Fixed());
        var tableCard = tableArea.Add(Panel.Col().Card().Scroll());
        var pivoted = trendData.Pivot("region", "month", "sales", 0);
        tableCard.Add(new SimpleTable(pivoted.WithTotalsRow(), CoreCss.BorderTable));

        // RIGHT PANEL (40%)
        var right = main.Add(Panel.Col().Percent(40));

        // Donut charts
        var topCard = right.Add(Panel.Col().Grow().Fill().Card());
        topCard.Add(new Div(CoreCss.ChartTitle).WithText("By Region"));
        topCard.Add(new ChartElement(
            new PieChart()
                .FromData(Adapter.GetByRegion(Filters), "region", "total")
                .WithHole(0.4)
                .WithFill()));

        var bottomCard = right.Add(Panel.Col().Grow().Fill().Card());
        bottomCard.Add(new Div(CoreCss.ChartTitle).WithText("By Product"));
        bottomCard.Add(new ChartElement(
            new PieChart()
                .FromData(Adapter.GetByProduct(Filters), "product", "total")
                .WithHole(0.4)
                .WithMaxSlices(5)
                .WithFill()));
    }
}

Using Filters in Queries

Adapter Pattern

Create query methods that accept FilterState:

public static class MyAdapter
{
    public static DbResults GetMonthlyData(FilterState filters)
    {
        var s = new StringBuilder();
        s.AppendLine("SELECT month, COUNT(*) as cnt");
        s.AppendLine("FROM MyTable");
        s.AppendLine(filters.BuildWhereClause());
        s.AppendLine("GROUP BY month");
        s.AppendLine("ORDER BY month");
        
        var q = new DbQuery(s.ToString());
        filters.AddParameters(q);
        return q.ExecuteReader();
    }
    
    public static int GetCount(FilterState filters, string status)
    {
        var s = new StringBuilder();
        s.AppendLine("SELECT COUNT(*)");
        s.AppendLine("FROM MyTable");
        s.AppendLine(filters.BuildWhereClause(excludeStatus: true));
        s.AppendLine("AND status = @status");
        
        var q = new DbQuery(s.ToString());
        filters.AddParameters(q, excludeStatus: true);
        q.AddParameter("@status", status);
        return q.ExecuteCount();
    }
}

BuildWhereClause

Generates SQL WHERE clause from active filters:

// With all filters:
filters.BuildWhereClause()
// Returns: "WHERE activity_datetime BETWEEN @StartDate AND @EndDate AND status = @Status ..."

// Excluding status (useful for KPIs that show count per status):
filters.BuildWhereClause(excludeStatus: true)
// Returns: "WHERE activity_datetime BETWEEN @StartDate AND @EndDate AND enterprise = @Enterprise ..."

AddParameters

Adds SqlParameters for active filters:

var q = new DbQuery(sql);
filters.AddParameters(q);  // Adds @StartDate, @EndDate, @Status, etc.

Page Lifecycle

  1. Request arrives → SCMApp.HandleRequest()
  2. Auth check → Redirect to login if needed
  3. Route to page → Create page instance
  4. BuildContent() called (in FilteredBasePage): load filters, save to cookies, build filter bar, call BuildPageContent(main)
  5. Your code runs → BuildPageContent() builds the dashboard
  6. Render → DOM tree converted to HTML

Tips

Reusing Data

Query once, use multiple times:

protected override void BuildPageContent(ContainerElement main)
{
    // Query once
    var data = Adapter.GetMonthlyData(Filters);
    
    // Use for chart
    chartCard.Add(new ChartElement(
        new BarChart().FromData(data, "month", "cnt").WithFill()));
    
    // Use for table (with pivot)
    var pivoted = data.Pivot("status", "month", "cnt", 0);
    tableCard.Add(new SimpleTable(pivoted.WithTotalsRow(), CoreCss.BorderTable));
}

Organizing Complex Pages

Extract sections into methods:

protected override void BuildPageContent(ContainerElement main)
{
    var left = main.Add(Panel.Col().Percent(60));
    BuildKpiRow(left);
    BuildTrendChart(left);
    BuildDataTable(left);
    
    var right = main.Add(Panel.Col().Percent(40));
    BuildDonutCharts(right);
}

private void BuildKpiRow(ContainerElement parent) { ... }
private void BuildTrendChart(ContainerElement parent) { ... }
private void BuildDataTable(ContainerElement parent) { ... }
private void BuildDonutCharts(ContainerElement parent) { ... }

Filter Dropdowns from Data

Populate dropdowns dynamically:

private DropDownList BuildFilterDropdown(string name, string label, string column, string selected)
{
    var dropdown = new DropDownList(name, label)
        .WithSelectedValue(selected)
        .WithAutoSubmit()
        .WithShowAllOption();  // Adds "All" as first option

    var values = Adapter.GetDistinctValues(column);
    foreach (var value in values)
    {
        dropdown.AddItem(value, value);
    }

    return dropdown;
}

← Back to Guide

Please wait...