docs/guides/tutorial-dashboard.md

# Tutorial: Building a Dashboard with PhiaUI

This guide walks you through building a complete analytics dashboard using PhiaUI components in Phoenix LiveView — from project setup to a fully functional interface with charts, data tables, KPI cards, and interactive elements.

**What we'll build:**
- A responsive shell with sidebar navigation
- KPI stat cards with trend indicators
- A revenue area chart (ECharts)
- A sortable data table with streams
- A command palette (Ctrl+K)
- Dark mode toggle
- A toast notification system

![Dashboard Preview](../dashboard-preview.png)

---

## Prerequisites

- Phoenix 1.8+ with LiveView 1.1+
- TailwindCSS v4 configured
- Node.js (for ECharts)

---

## Step 1 — Install PhiaUI

Add to `mix.exs`:

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

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

This copies JS hooks to `assets/js/phia_hooks/` and creates a theme reference file.

---

## Step 2 — Configure TailwindCSS

In `assets/css/app.css`:

```css
@import "tailwindcss";
@import "../../../deps/phia_ui/priv/static/theme.css";
```

The theme file provides semantic OKLCH design tokens (`bg-primary`, `text-muted-foreground`, etc.) and automatic dark mode support.

---

## Step 3 — Eject the components we need

```bash
mix phia.add shell sidebar topbar dark_mode_toggle
mix phia.add stat_card metric_grid chart_shell phia_chart
mix phia.add table data_grid
mix phia.add command toast button badge
mix phia.add dialog alert_dialog
```

Each command copies Elixir modules to `lib/your_app_web/components/ui/`.

---

## Step 4 — Register JS Hooks

In `assets/js/app.js`:

```javascript
import PhiaDarkMode        from "./phia_hooks/dark_mode"
import PhiaCommand         from "./phia_hooks/command"
import PhiaToast           from "./phia_hooks/toast"
import PhiaChart           from "./phia_hooks/chart"
import PhiaDialog          from "./phia_hooks/dialog"
import PhiaDropdownMenu    from "./phia_hooks/dropdown_menu"
import PhiaTooltip         from "./phia_hooks/tooltip"

let liveSocket = new LiveSocket("/live", Socket, {
  params: { _csrf_token: csrfToken },
  hooks: {
    PhiaDarkMode,
    PhiaCommand,
    PhiaToast,
    PhiaChart,
    PhiaDialog,
    PhiaDropdownMenu,
    PhiaTooltip,
  }
})
```

---

## Step 5 — Install ECharts (for charts)

```bash
cd assets && npm install echarts
```

In `assets/js/app.js`, import before liveSocket:

```javascript
import * as echarts from "echarts"
window.echarts = echarts
```

---

## Step 6 — Create the Dashboard LiveView

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

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

  import YourAppWeb.Components.UI.Shell
  import YourAppWeb.Components.UI.Sidebar
  import YourAppWeb.Components.UI.Topbar
  import YourAppWeb.Components.UI.DarkModeToggle
  import YourAppWeb.Components.UI.StatCard
  import YourAppWeb.Components.UI.MetricGrid
  import YourAppWeb.Components.UI.PhiaChart
  import YourAppWeb.Components.UI.DataGrid
  import YourAppWeb.Components.UI.Command
  import YourAppWeb.Components.UI.Toast
  import YourAppWeb.Components.UI.Button
  import YourAppWeb.Components.UI.Badge

  @impl true
  def mount(_params, _session, socket) do
    {:ok,
     socket
     |> assign(:page_title, "Dashboard")
     |> assign(:command_open, false)
     |> assign(:sort_by, "name")
     |> assign(:sort_dir, "asc")
     |> assign(:orders, sample_orders())
     |> assign(:mrr_data, [38_200, 41_500, 39_800, 44_100, 46_300, 48_290])
     |> assign(:month_labels, ~w(Oct Nov Dec Jan Feb Mar))}
  end

  @impl true
  def render(assigns) do
    ~H"""
    <%!-- Mount the toast viewport once --%>
    <.toast id="toast-viewport" />

    <%!-- Command palette (Ctrl+K) --%>
    <.command id="cmd" open={@command_open}>
      <.command_input id="cmd-input" on_change="search" placeholder="Search…" />
      <.command_list id="cmd-results">
        <.command_empty>No results found.</.command_empty>
        <.command_group label="Navigation">
          <.command_item on_click="navigate" value="/dashboard">Dashboard</.command_item>
          <.command_item on_click="navigate" value="/orders">Orders</.command_item>
          <.command_item on_click="navigate" value="/customers">Customers</.command_item>
        </.command_group>
      </.command_list>
    </.command>

    <%!-- Main shell layout --%>
    <.shell>
      <:sidebar>
        <.sidebar>
          <:brand>
            <div class="flex items-center gap-2 px-6 py-4">
              <div class="h-8 w-8 rounded-lg bg-primary flex items-center justify-center">
                <span class="text-primary-foreground font-bold text-sm">A</span>
              </div>
              <span class="font-semibold">Acme Corp</span>
            </div>
          </:brand>
          <:nav>
            <nav class="px-3 space-y-1">
              <.sidebar_item href="/dashboard" active={true}>
                <.icon name="layout-dashboard" size="sm" class="mr-2" />
                Dashboard
              </.sidebar_item>
              <.sidebar_item href="/orders">
                <.icon name="shopping-cart" size="sm" class="mr-2" />
                Orders
              </.sidebar_item>
              <.sidebar_item href="/customers">
                <.icon name="users" size="sm" class="mr-2" />
                Customers
              </.sidebar_item>
              <.sidebar_item href="/analytics">
                <.icon name="bar-chart-2" size="sm" class="mr-2" />
                Analytics
              </.sidebar_item>
              <.sidebar_item href="/settings">
                <.icon name="settings" size="sm" class="mr-2" />
                Settings
              </.sidebar_item>
            </nav>
          </:nav>
          <:footer>
            <div class="px-3 py-4 flex items-center justify-between">
              <div class="flex items-center gap-2">
                <.avatar size="sm">
                  <.avatar_fallback name="Admin User" />
                </.avatar>
                <span class="text-sm font-medium">Admin</span>
              </div>
              <.dark_mode_toggle />
            </div>
          </:footer>
        </.sidebar>
      </:sidebar>
      <:topbar>
        <.topbar>
          <:left>
            <h1 class="text-lg font-semibold">Dashboard</h1>
          </:left>
          <:right>
            <.button variant="outline" size="sm" phx-click="open-command">
              <.icon name="search" size="sm" class="mr-2" />
              Search
              <kbd class="ml-2 text-xs bg-muted px-1.5 py-0.5 rounded">⌘K</kbd>
            </.button>
          </:right>
        </.topbar>
      </:topbar>
      <:content>
        <div class="p-6 space-y-6">
          <%!-- KPI Cards --%>
          <.metric_grid cols={4}>
            <.stat_card
              title="Monthly Revenue"
              value="$48,290"
              trend="up"
              trend_value="+12.5%"
              description="vs last month"
            />
            <.stat_card
              title="Active Users"
              value="2,840"
              trend="up"
              trend_value="+8.2%"
              description="vs last month"
            />
            <.stat_card
              title="Churn Rate"
              value="3.1%"
              trend="down"
              trend_value="-0.4%"
              description="improvement"
            />
            <.stat_card
              title="NPS Score"
              value="67"
              trend="neutral"
              trend_value="0"
              description="no change"
            />
          </.metric_grid>

          <%!-- Revenue Chart --%>
          <.phia_chart
            id="revenue-chart"
            type={:area}
            title="Monthly Revenue"
            series={[%{name: "MRR", data: @mrr_data}]}
            labels={@month_labels}
            height="300px"
          />

          <%!-- Orders Table --%>
          <.card>
            <.card_header>
              <div class="flex items-center justify-between">
                <.card_title>Recent Orders</.card_title>
                <.button variant="outline" size="sm">View All</.button>
              </div>
            </.card_header>
            <.card_content class="p-0">
              <.data_grid
                id="orders-grid"
                rows={@orders}
                sort_by={@sort_by}
                sort_dir={@sort_dir}
                on_sort="sort-orders"
              >
                <:col field="id" label="Order #" sortable={true} />
                <:col field="customer" label="Customer" sortable={true} />
                <:col field="amount" label="Amount" sortable={true} />
                <:col field="status" label="Status">
                  <:cell :let={row}>
                    <.badge variant={status_variant(row.status)}>{row.status}</.badge>
                  </:cell>
                </:col>
                <:col field="date" label="Date" sortable={true} />
              </.data_grid>
            </.card_content>
          </.card>
        </div>
      </:content>
    </.shell>
    """
  end

  @impl true
  def handle_event("open-command", _, socket) do
    {:noreply, assign(socket, :command_open, true)}
  end

  def handle_event("sort-orders", %{"field" => field, "dir" => dir}, socket) do
    sorted = sort_orders(socket.assigns.orders, field, dir)
    {:noreply, assign(socket, sort_by: field, sort_dir: dir, orders: sorted)}
  end

  def handle_event("notify", _, socket) do
    {:noreply, push_event(socket, "phia-toast", %{
      title: "Success",
      description: "Dashboard refreshed.",
      variant: "success"
    })}
  end

  # Helpers

  defp status_variant("paid"), do: "default"
  defp status_variant("pending"), do: "secondary"
  defp status_variant("failed"), do: "destructive"
  defp status_variant(_), do: "outline"

  defp sample_orders do
    [
      %{id: "#1042", customer: "Alice Martin",  amount: "$320.00", status: "paid",    date: "Mar 3"},
      %{id: "#1041", customer: "Bob Chen",      amount: "$89.50",  status: "pending", date: "Mar 2"},
      %{id: "#1040", customer: "Carol White",   amount: "$1,200",  status: "paid",    date: "Mar 1"},
      %{id: "#1039", customer: "David Kim",     amount: "$45.00",  status: "failed",  date: "Feb 28"},
      %{id: "#1038", customer: "Emma Davis",    amount: "$660.00", status: "paid",    date: "Feb 27"},
    ]
  end

  defp sort_orders(orders, field, "asc"),  do: Enum.sort_by(orders, &Map.get(&1, String.to_existing_atom(field)))
  defp sort_orders(orders, field, "desc"), do: Enum.sort_by(orders, &Map.get(&1, String.to_existing_atom(field)), :desc)
  defp sort_orders(orders, _field, _),     do: orders
end
```

---

## Step 7 — Add the route

In `lib/your_app_web/router.ex`:

```elixir
scope "/", YourAppWeb do
  pipe_through :browser

  live "/dashboard", DashboardLive
end
```

---

## Step 8 — Add dark mode support to root layout

In `lib/your_app_web/components/layouts/root.html.heex`, add the `PhiaDarkMode` hook to the `<html>` tag:

```heex
<html lang="en" phx-hook="PhiaDarkMode" id="html-root">
```

This hook reads `localStorage` on mount and applies/removes the `.dark` class automatically.

---

## Step 9 — Run the application

```bash
mix phx.server
```

Visit [http://localhost:4000/dashboard](http://localhost:4000/dashboard) — you should see:

- ✅ Sidebar with navigation links and dark mode toggle
- ✅ Top bar with Ctrl+K search trigger
- ✅ 4 KPI stat cards with trend indicators
- ✅ Area chart showing MRR over 6 months
- ✅ Sortable orders table with status badges

---

## Going further

### Real-time updates with push_event

Push live chart updates from your LiveView:

```elixir
def handle_info({:new_order, order}, socket) do
  new_data = socket.assigns.mrr_data ++ [order.amount]
  {:noreply, socket
    |> assign(:mrr_data, new_data)
    |> push_event("update-chart-revenue-chart", %{
        series: [%{name: "MRR", data: new_data}]
      })}
end
```

### Toast notifications

```elixir
def handle_event("save", params, socket) do
  case MyApp.save(params) do
    {:ok, _} ->
      {:noreply, push_event(socket, "phia-toast", %{
        title: "Saved",
        description: "Your changes have been saved.",
        variant: "success"
      })}
    {:error, changeset} ->
      {:noreply, push_event(socket, "phia-toast", %{
        title: "Error",
        description: "Please check the form for errors.",
        variant: "error"
      })}
  end
end
```

### Streams for large datasets

For tables with hundreds or thousands of rows, use LiveView streams:

```elixir
def mount(_, _, socket) do
  {:ok, stream(socket, :orders, MyApp.list_orders())}
end

# In template:
# <.table_body id="orders-body" phx-update="stream">
#   <.table_row :for={{id, order} <- @streams.orders} id={id}>
#     ...
#   </.table_row>
# </.table_body>
```

### Adding a confirmation dialog

```heex
<.alert_dialog id="delete-order" open={@confirm_delete}>
  <.alert_dialog_header>
    <.alert_dialog_title>Delete order?</.alert_dialog_title>
    <.alert_dialog_description>
      Order {@order_to_delete} will be permanently removed.
    </.alert_dialog_description>
  </.alert_dialog_header>
  <.alert_dialog_footer>
    <.alert_dialog_cancel phx-click="cancel-delete">Cancel</.alert_dialog_cancel>
    <.alert_dialog_action variant="destructive" phx-click="confirm-delete">
      Delete Order
    </.alert_dialog_action>
  </.alert_dialog_footer>
</.alert_dialog>
```

### Enterprise components — FilterBar + BulkActionBar

For a more powerful data management interface, add filtering and bulk operations:

```bash
mix phia.add filter_bar bulk_action_bar step_tracker activity_feed
```

```elixir
# In your LiveView mount/3:
|> assign(:selected_ids, [])
|> assign(:filters, %{search: "", status: "all"})
|> assign(:active_filters, [])

# Handle filter changes:
def handle_event("filter-search", %{"value" => q}, socket) do
  {:noreply, put_in(socket.assigns.filters.search, q) |> apply_filters()}
end

def handle_event("bulk-delete", _, socket) do
  ids = socket.assigns.selected_ids
  MyApp.delete_orders(ids)
  {:noreply, assign(socket, selected_ids: [])}
end
```

```heex
<%!-- Bulk action bar — hides when count = 0 --%>
<.bulk_action_bar count={length(@selected_ids)} label="orders selected">
  <.bulk_action icon="download" on_click="bulk-export">Export</.bulk_action>
  <.bulk_action icon="trash-2" variant="destructive" on_click="bulk-delete">Delete</.bulk_action>
</.bulk_action_bar>

<%!-- FilterBar above the data grid --%>
<.filter_bar>
  <.filter_search placeholder="Search orders…" on_search="filter-search" />
  <.filter_select label="Status" options={[{"All", "all"}, {"Paid", "paid"}, {"Pending", "pending"}]}
    value={@filters.status} on_change="filter-status" />
  <.filter_reset on_click="reset-filters" />
</.filter_bar>
```

### Activity Feed for audit logs

```heex
<.activity_feed>
  <%= for event <- @audit_log do %>
    <.activity_item
      actor={event.user}
      action={event.action}
      target={event.resource}
      timestamp={event.inserted_at}
      icon={activity_icon(event.type)}
    />
  <% end %>
</.activity_feed>
```

### StepTracker for onboarding flows

```heex
<.step_tracker>
  <.step status="complete" title="Account" />
  <.step status="active" title="Profile" description="Add your photo and bio" />
  <.step status="upcoming" title="Billing" />
  <.step status="upcoming" title="Done" />
</.step_tracker>
```

### Adding filters with a Drawer

```heex
<.drawer_content id="filters-drawer" open={@filters_open} direction="right">
  <.drawer_header>
    <h2 class="text-lg font-semibold">Filters</h2>
  </.drawer_header>
  <.drawer_close />
  <div class="px-6 space-y-4">
    <.field>
      <.field_label>Date Range</.field_label>
      <.date_range_picker id="dr" value={@date_range} />
    </.field>
    <.field>
      <.field_label>Status</.field_label>
      <.combobox
        id="status-filter"
        options={[
          %{value: "all", label: "All"},
          %{value: "paid", label: "Paid"},
          %{value: "pending", label: "Pending"},
          %{value: "failed", label: "Failed"}
        ]}
        value={@status_filter}
        open={@filter_combobox_open}
        search={@filter_search}
        on_toggle="toggle-filter-combo"
        on_change="set-status-filter"
        on_search="search-filter"
      />
    </.field>
  </div>
  <.drawer_footer>
    <.button phx-click="apply-filters">Apply</.button>
    <.button variant="outline" phx-click="reset-filters">Reset</.button>
  </.drawer_footer>
</.drawer_content>
```

---

## Complete component reference (75 components)

| Category | Components |
|----------|-----------|
| Layout | `shell/1`, `sidebar/1`, `sidebar_item/1`, `sidebar_section/1`, `topbar/1`, `mobile_sidebar_toggle/1` |
| KPI | `stat_card/1`, `metric_grid/1`, `chart_shell/1` |
| Charts | `phia_chart/1` |
| Data | `table/1`, `data_grid/1` |
| Feedback | `toast/1`, `alert/1`, `badge/1`, `skeleton/1`, `progress/1` |
| Inputs | `phia_input/1`, `phia_textarea/1`, `phia_select/1`, `checkbox/1`, `form_checkbox/1`, `switch/1`, `slider/1`, `form_slider/1`, `rating/1`, `form_rating/1`, `radio_group/1`, `tags_input/1`, `combobox/1`, `date_picker/1`, `date_range_picker/1`, `calendar/1`, `rich_text_editor/1`, `image_upload/1` |
| Navigation | `breadcrumb/1`, `pagination/1`, `command/1`, `navigation_menu/1`, `tabs_nav/1` |
| Overlay | `dialog/1`, `alert_dialog/1`, `drawer/1`, `popover/1`, `tooltip/1`, `hover_card/1`, `context_menu/1` |
| Display | `accordion/1`, `tabs/1`, `collapsible/1`, `carousel/1`, `toggle/1`, `toggle_group/1` |
| Utility | `aspect_ratio/1`, `direction/1`, `empty/1`, `field/1`, `button_group/1`, `avatar/1`, `avatar_group/1`, `scroll_area/1`, `separator/1`, `resizable/1`, `timeline/1`, `kbd/1`, `theme_provider/1`, `dark_mode_toggle/1` |
| Enterprise | `activity_feed/1`, `heatmap_calendar/1`, `kanban_board/1`, `chat_message/1`, `mention_input/1`, `filter_bar/1`, `filter_builder/1`, `bulk_action_bar/1`, `step_tracker/1`, `navigation_menu/1` |

← [Back to README](../../README.md)