docs/guides/tutorial-dashboard.md

# Tutorial: Analytics Dashboard

Build a complete, production-ready SaaS analytics dashboard using PhiaUI components in Phoenix LiveView. You will wire together a sidebar shell, KPI metric cards, a revenue chart, a sortable data table with server-side pagination, a command palette, and toast notifications — all with dark mode support.

## What you'll build

- A responsive app shell with collapsible sidebar navigation and a topbar
- Four KPI stat cards (MRR, Active Users, Churn Rate, NPS Score) using `metric_grid`
- A revenue area chart powered by ECharts via `phia_chart`
- A sortable, paginated user table using `data_grid` + `pagination`
- A Ctrl+K command palette with `command`
- Dark mode toggle in the topbar
- Toast notifications on save/export actions

## Prerequisites

- Elixir 1.17+ and Phoenix 1.8+ with LiveView 1.1+
- TailwindCSS v4 configured in your project
- Node.js 18+ (for ECharts via npm)
- PhiaUI added to your `mix.exs` dependencies

---

## Step 1 — Install PhiaUI and configure CSS

Add PhiaUI to `mix.exs`:

```elixir
def deps do
  [
    {:phia_ui, "~> 0.1.5"}
  ]
end
```

Run:

```bash
mix deps.get
mix phia.install
```

Import the PhiaUI CSS layer in `assets/css/app.css`:

```css
@import "../../deps/phia_ui/priv/static/theme.css";
@import "./phia-themes.css";    /* generated by mix phia.theme install */
```

Install a colour theme (optional, defaults to `zinc`):

```bash
mix phia.theme install blue
```

---

## Step 2 — Eject components

Eject only the components this dashboard needs:

```bash
mix phia.add shell sidebar topbar dark_mode_toggle stat_card metric_grid \
  chart_shell phia_chart table data_grid pagination command toast button \
  badge icon spinner
```

This copies each component's source file into `lib/my_app_web/components/phia/` so you own the code and can customise it freely.

> **Tip:** Run `mix phia.add --list` to see every available component and its dependencies before ejecting.

---

## Step 3 — Register JavaScript hooks in `app.js`

PhiaUI ships hook files into `priv/templates/js/hooks/` during `mix phia.install`. Import and register the hooks your dashboard needs:

```javascript
// assets/js/app.js
import { Socket } from "phoenix";
import { LiveSocket } from "phoenix_live_view";

// PhiaUI hooks
import PhiaDarkMode   from "./hooks/dark_mode";
import PhiaChart      from "./hooks/chart";
import PhiaCommand    from "./hooks/command";
import PhiaToast      from "./hooks/toast";
import PhiaDropdownMenu from "./hooks/dropdown_menu";

const Hooks = {
  PhiaDarkMode,
  PhiaChart,
  PhiaCommand,
  PhiaToast,
  PhiaDropdownMenu,
};

const liveSocket = new LiveSocket("/live", Socket, {
  params: { _csrf_token: csrfToken },
  hooks: Hooks,
});

liveSocket.connect();
window.liveSocket = liveSocket;
```

---

## Step 4 — Create `DashboardLive`

Create `lib/my_app_web/live/dashboard_live.ex`:

```elixir
defmodule MyAppWeb.DashboardLive do
  use MyAppWeb, :live_view

  alias MyApp.Analytics
  alias MyApp.Accounts

  @page_size 10

  @impl true
  def mount(_params, _session, socket) do
    socket =
      socket
      |> assign(:page_title, "Analytics Dashboard")
      |> assign(:stats, Analytics.get_dashboard_stats())
      |> assign(:chart_series, Analytics.revenue_chart_series())
      |> assign(:sort_by, :inserted_at)
      |> assign(:sort_dir, :desc)
      |> assign(:page, 1)
      |> assign(:total_pages, ceil(Accounts.count_users() / @page_size))
      |> assign(:command_items, command_items())
      |> stream(:users, Accounts.list_users(sort: :inserted_at, dir: :desc, page: 1))

    {:ok, socket}
  end

  @impl true
  def handle_event("sort_users", %{"field" => field}, socket) do
    field_atom = String.to_existing_atom(field)

    new_dir =
      if socket.assigns.sort_by == field_atom do
        toggle_dir(socket.assigns.sort_dir)
      else
        :asc
      end

    users =
      Accounts.list_users(
        sort: field_atom,
        dir: new_dir,
        page: socket.assigns.page
      )

    socket =
      socket
      |> assign(:sort_by, field_atom)
      |> assign(:sort_dir, new_dir)
      |> stream(:users, users, reset: true)

    {:noreply, socket}
  end

  @impl true
  def handle_event("paginate", %{"page" => page_str}, socket) do
    page = String.to_integer(page_str)

    users =
      Accounts.list_users(
        sort: socket.assigns.sort_by,
        dir: socket.assigns.sort_dir,
        page: page
      )

    socket =
      socket
      |> assign(:page, page)
      |> stream(:users, users, reset: true)

    {:noreply, socket}
  end

  @impl true
  def handle_event("export_csv", _params, socket) do
    # Trigger background export job (e.g. via Oban)
    Analytics.enqueue_csv_export(socket.assigns.current_user.id)

    socket =
      socket
      |> put_flash(:info, "Your CSV export is being prepared. Check your email shortly.")

    {:noreply, socket}
  end

  @impl true
  def handle_event("command_navigate", %{"href" => href}, socket) do
    {:noreply, push_navigate(socket, to: href)}
  end

  # ── Private ──────────────────────────────────────────────────────────────

  defp toggle_dir(:asc), do: :desc
  defp toggle_dir(:desc), do: :asc

  defp command_items do
    [
      %{id: "dashboard", label: "Dashboard", icon: "hero-squares-2x2", href: "/dashboard"},
      %{id: "users",     label: "Users",     icon: "hero-users",        href: "/users"},
      %{id: "billing",   label: "Billing",   icon: "hero-credit-card",  href: "/billing"},
      %{id: "settings",  label: "Settings",  icon: "hero-cog-6-tooth",  href: "/settings"},
      %{id: "export",    label: "Export CSV", icon: "hero-arrow-down-tray", href: "#",
        action: "export_csv"},
    ]
  end
end
```

### Analytics context helpers

Add these to `lib/my_app/analytics.ex`:

```elixir
defmodule MyApp.Analytics do
  @doc """
  Returns the four KPI stats displayed on the dashboard header.
  """
  def get_dashboard_stats do
    [
      %{
        label: "Monthly Recurring Revenue",
        value: "$48,295",
        trend: +12.4,
        icon: "hero-banknotes",
        color: :green
      },
      %{
        label: "Active Users",
        value: "3,842",
        trend: +5.1,
        icon: "hero-users",
        color: :blue
      },
      %{
        label: "Churn Rate",
        value: "2.3%",
        trend: -0.8,
        icon: "hero-arrow-trending-down",
        color: :red
      },
      %{
        label: "NPS Score",
        value: "67",
        trend: +3.0,
        icon: "hero-star",
        color: :violet
      }
    ]
  end

  @doc """
  Returns 12-month revenue series for the ECharts area chart.
  """
  def revenue_chart_series do
    months = ~w[Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec]

    mrr = [32_000, 34_500, 33_800, 36_200, 37_900, 40_100,
           41_500, 39_800, 43_200, 45_700, 46_900, 48_295]

    arr = Enum.map(mrr, &(&1 * 12))

    %{
      type:       "line",
      categories: months,
      series: [
        %{name: "MRR ($)",  data: mrr, area: true},
        %{name: "ARR ($)",  data: arr, area: false},
      ]
    }
  end

  def enqueue_csv_export(user_id) do
    # Oban / background job integration left to the application
    :ok
  end
end
```

---

## Step 5 — Build the shell layout template

Create `lib/my_app_web/live/dashboard_live.html.heex`:

```heex
<.shell>
  <:sidebar>
    <.sidebar brand="Acme Analytics" brand_href="/dashboard">
      <.sidebar_section label="Main">
        <.sidebar_item icon="hero-squares-2x2" href="/dashboard" active>
          Dashboard
        </.sidebar_item>
        <.sidebar_item icon="hero-users" href="/users">
          Users
        </.sidebar_item>
        <.sidebar_item icon="hero-credit-card" href="/billing">
          Billing
        </.sidebar_item>
      </.sidebar_section>

      <.sidebar_section label="System">
        <.sidebar_item icon="hero-cog-6-tooth" href="/settings">
          Settings
        </.sidebar_item>
      </.sidebar_section>
    </.sidebar>
  </:sidebar>

  <:topbar>
    <.topbar>
      <:left>
        <span class="text-sm font-medium text-muted-foreground">
          Analytics Dashboard
        </span>
      </:left>
      <:right>
        <.button variant="ghost" size="sm" phx-click={JS.dispatch("phia:command:open")}>
          <.icon name="hero-magnifying-glass" class="size-4 mr-2" />
          Search...
          <kbd class="ml-3 text-xs text-muted-foreground">⌘K</kbd>
        </.button>

        <.dark_mode_toggle />

        <.button variant="outline" size="sm" phx-click="export_csv">
          <.icon name="hero-arrow-down-tray" class="size-4 mr-2" />
          Export
        </.button>
      </:right>
    </.topbar>
  </:topbar>

  <:content>
    <%# ── KPI cards ─────────────────────────────────────────────── %>
    <section class="p-6 space-y-6">
      <div>
        <h1 class="text-2xl font-bold tracking-tight">Overview</h1>
        <p class="text-sm text-muted-foreground mt-1">
          Last updated: <%= Calendar.strftime(DateTime.utc_now(), "%B %d, %Y") %>
        </p>
      </div>

      <.metric_grid columns={4}>
        <.stat_card
          :for={stat <- @stats}
          label={stat.label}
          value={stat.value}
          trend={stat.trend}
          icon={stat.icon}
          color={stat.color}
        />
      </.metric_grid>

      <%# ── Revenue chart ───────────────────────────────────────── %>
      <.chart_shell title="Revenue Trend" subtitle="Monthly vs Annual Recurring Revenue">
        <.phia_chart
          id="revenue-chart"
          type={@chart_series.type}
          categories={@chart_series.categories}
          series={@chart_series.series}
          height="320px"
        />
      </.chart_shell>

      <%# ── User table ──────────────────────────────────────────── %>
      <div class="rounded-lg border bg-card">
        <div class="flex items-center justify-between px-6 py-4 border-b">
          <h2 class="text-base font-semibold">Users</h2>
          <.badge variant="secondary"><%= @total_pages * @page_size %> total</.badge>
        </div>

        <.data_grid
          id="users-grid"
          rows={@streams.users}
          sort_by={@sort_by}
          sort_dir={@sort_dir}
          on_sort="sort_users"
        >
          <:col label="Name"      field={:name}        sortable />
          <:col label="Email"     field={:email}       sortable />
          <:col label="Plan"      field={:plan} >
            <:cell :let={user}>
              <.badge variant={plan_variant(user.plan)}><%= user.plan %></.badge>
            </:cell>
          </:col>
          <:col label="Joined"    field={:inserted_at} sortable />
          <:col label="MRR"       field={:mrr}         sortable />
          <:col label="Actions">
            <:cell :let={user}>
              <.button variant="ghost" size="icon" navigate={~p"/users/#{user.id}"}>
                <.icon name="hero-eye" class="size-4" />
              </.button>
            </:cell>
          </:col>
        </.data_grid>

        <div class="px-6 py-4 border-t">
          <.pagination
            page={@page}
            total_pages={@total_pages}
            on_change="paginate"
          />
        </div>
      </div>
    </section>

    <%# ── Command palette ────────────────────────────────────────── %>
    <.command
      id="dashboard-command"
      placeholder="Search pages, actions..."
      on_select="command_navigate"
    >
      <:item :for={item <- @command_items}
             id={item.id}
             label={item.label}
             icon={item.icon}
             value={item.href} />
    </.command>

    <%# ── Toast container ────────────────────────────────────────── %>
    <.toast id="dashboard-toast" />
  </:content>
</.shell>
```

---

## Step 6 — KPI cards with `metric_grid`

The `stat_card` component accepts `trend` as a signed float. A positive value renders a green upward arrow; a negative value renders a red downward arrow.

```heex
<.metric_grid columns={4}>
  <.stat_card
    label="Monthly Recurring Revenue"
    value="$48,295"
    trend={+12.4}
    icon="hero-banknotes"
    color={:green}
  />
  <.stat_card
    label="Active Users"
    value="3,842"
    trend={+5.1}
    icon="hero-users"
    color={:blue}
  />
  <.stat_card
    label="Churn Rate"
    value="2.3%"
    trend={-0.8}
    icon="hero-arrow-trending-down"
    color={:red}
  />
  <.stat_card
    label="NPS Score"
    value="67"
    trend={+3.0}
    icon="hero-star"
    color={:violet}
  />
</.metric_grid>
```

> **Tip:** On mobile, `metric_grid` collapses to a 2-column layout automatically via the responsive classes baked into the component. No extra CSS needed.

---

## Step 7 — Revenue area chart with `phia_chart`

The `phia_chart` component wraps ECharts. Pass `:area true` in a series entry to render a filled area beneath the line:

```heex
<.phia_chart
  id="revenue-chart"
  type="line"
  categories={["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]}
  series={[
    %{name: "MRR ($)",  data: [32_000,34_500,33_800,36_200,37_900,40_100,41_500,39_800,43_200,45_700,46_900,48_295], area: true},
    %{name: "ARR ($)",  data: [384_000,414_000,405_600,434_400,454_800,481_200,498_000,477_600,518_400,548_400,562_800,579_540], area: false},
  ]}
  height="320px"
/>
```

The `PhiaChart` hook reads the `data-config` attribute serialised by the component and initialises the ECharts instance. Resizing is handled automatically by a `ResizeObserver`.

---

## Step 8 — Sortable data table with `data_grid` + `pagination`

The `data_grid` component fires a `phx-click` bound to `on_sort` when the user clicks a sortable column header. You receive the field name as a string and must convert it with `String.to_existing_atom/1`:

```elixir
def handle_event("sort_users", %{"field" => field}, socket) do
  field_atom = String.to_existing_atom(field)

  new_dir =
    if socket.assigns.sort_by == field_atom,
      do: toggle_dir(socket.assigns.sort_dir),
      else: :asc

  users = Accounts.list_users(sort: field_atom, dir: new_dir, page: socket.assigns.page)

  {:noreply,
   socket
   |> assign(:sort_by, field_atom)
   |> assign(:sort_dir, new_dir)
   |> stream(:users, users, reset: true)}
end
```

The Ecto query in `Accounts.list_users/1` uses `offset`/`limit` for pagination:

```elixir
def list_users(opts \\ []) do
  sort  = Keyword.get(opts, :sort, :inserted_at)
  dir   = Keyword.get(opts, :dir, :desc)
  page  = Keyword.get(opts, :page, 1)
  limit = 10

  from(u in User,
    order_by: [{^dir, ^sort}],
    limit: ^limit,
    offset: ^((page - 1) * limit)
  )
  |> Repo.all()
end
```

---

## Step 9 — Command palette (Ctrl+K)

The `command` component listens for the global keyboard shortcut via the `PhiaCommand` hook. Open it programmatically with `JS.dispatch("phia:command:open")`:

```elixir
defp command_items do
  [
    %{id: "dashboard", label: "Dashboard",  icon: "hero-squares-2x2",    href: "/dashboard"},
    %{id: "users",     label: "Users",      icon: "hero-users",          href: "/users"},
    %{id: "billing",   label: "Billing",    icon: "hero-credit-card",    href: "/billing"},
    %{id: "settings",  label: "Settings",   icon: "hero-cog-6-tooth",    href: "/settings"},
  ]
end
```

```heex
<.command
  id="dashboard-command"
  placeholder="Search pages, actions..."
  on_select="command_navigate"
>
  <:item :for={item <- @command_items}
         id={item.id}
         label={item.label}
         icon={item.icon}
         value={item.href} />
</.command>
```

Handle navigation:

```elixir
def handle_event("command_navigate", %{"href" => href}, socket) do
  {:noreply, push_navigate(socket, to: href)}
end
```

---

## Step 10 — Toast notifications

The `toast` component is positioned fixed at the bottom-right by default. Trigger it via `put_flash/3` or directly via `push_event/3`:

```elixir
def handle_event("export_csv", _params, socket) do
  Analytics.enqueue_csv_export(socket.assigns.current_user.id)

  {:noreply, push_event(socket, "phia:toast", %{
    message: "Export queued — check your email shortly.",
    type: "success",
    duration: 4000
  })}
end
```

```heex
<%# Place once in your layout, outside any scrollable container %>
<.toast id="dashboard-toast" />
```

---

## Helper: badge variant for plan tier

```elixir
defp plan_variant("enterprise"), do: :default
defp plan_variant("pro"),        do: :secondary
defp plan_variant("starter"),    do: :outline
defp plan_variant(_),            do: :ghost
```

---

## Final result

After completing all steps you will have a fully functional SaaS analytics dashboard that:

- Renders inside a responsive shell with a collapsible sidebar and a topbar
- Shows four real-time KPI cards that reflect data from your Analytics context
- Displays a 12-month revenue area chart powered by ECharts
- Lists users in a sortable, server-paginated data grid backed by Phoenix streams
- Lets users search and navigate with a Ctrl+K command palette
- Sends toast notifications for async operations like CSV export
- Supports dark mode without any layout shift on first paint

### Related components

- [phia_chart](data.md#phia_chart)
- [data_grid](data.md#data_grid)
- [pagination](data.md#pagination)
- [command](overlay.md#command)
- [metric_grid + stat_card](cards.md#stat_card)
- [shell + sidebar + topbar](layout.md#shell)

---