Ad – 728×90
🦉 Project

OWL JS Dashboard Component – KPI Cards, Charts & Live Data

Dashboards are the most demanding OWL projects: they fetch data asynchronously before rendering, need to cancel in-flight requests when the user navigates away, refresh on a timer, integrate third-party libraries (Chart.js) that need a DOM element to mount into, and handle error states gracefully. This project builds a complete sales dashboard as an Odoo client action — the same architecture used in real Odoo modules like Sales, CRM, and Inventory.

⏱️ 60 min 🎯 Advanced 📅 Updated 2026

What You'll Build

  • Four KPI cards: Total Revenue, Orders, New Customers, Avg Order Value
  • Bar chart of monthly revenue (Chart.js)
  • Recent orders data table with status badges
  • Date range filter (This Month / Last 3 Months / This Year)
  • Auto-refresh every 60 seconds
  • Loading skeleton and error state
  • Registered as an Odoo client action

Component Structure

my_module/
└── static/src/
    ├── dashboard/
    │   ├── Dashboard.js          ← root component, data layer
    │   ├── Dashboard.xml
    │   ├── KpiCard.js            ← stateless card component
    │   ├── KpiCard.xml
    │   ├── RevenueChart.js       ← Chart.js wrapper
    │   ├── RevenueChart.xml
    │   └── OrdersTable.js        ← recent orders table
    │       OrdersTable.xml
    └── index.js

KpiCard Component

Purely presentational — no state, receives all data as props.

// KpiCard.js
/** @odoo-module **/
import { Component } from "@odoo/owl";

export class KpiCard extends Component {
  static template = "my_module.KpiCard";

  static props = {
    label:  String,
    value:  [String, Number],
    change: { type: Number, optional: true },   // % change vs previous period
    icon:   { type: String, optional: true },
    color:  { type: String, optional: true },
  };

  static defaultProps = { change: null, icon: "📊", color: "#2271b1" };

  get changeLabel() {
    if (this.props.change === null) return "";
    const sign = this.props.change >= 0 ? "+" : "";
    return `${sign}${this.props.change.toFixed(1)}%`;
  }

  get isPositive() { return this.props.change >= 0; }
}
<!-- KpiCard.xml -->
<templates>
  <t t-name="my_module.KpiCard">
    <div class="kpi-card" t-att-style="'border-top: 3px solid ' + props.color">
      <div class="kpi-icon"><t t-esc="props.icon" /></div>
      <div class="kpi-body">
        <div class="kpi-label" t-esc="props.label" />
        <div class="kpi-value" t-esc="props.value" />
        <div
          t-if="props.change !== null"
          t-att-class="'kpi-change ' + (isPositive ? 'positive' : 'negative')"
          t-esc="changeLabel"
        />
      </div>
    </div>
  </t>
</templates>
Ad – 336×280

RevenueChart Component (Chart.js)

Chart.js needs a <canvas> element. Use useRef + onMounted to initialise the chart instance, and onPatched to update it when data changes.

// RevenueChart.js
/** @odoo-module **/
import { Component, useRef, onMounted, onPatched, onWillUnmount } from "@odoo/owl";
// Chart.js loaded as an asset in __manifest__.py
// import Chart from "chart.js/auto";  — or via globalThis.Chart

export class RevenueChart extends Component {
  static template = "my_module.RevenueChart";

  static props = {
    labels: Array,   // ["Jan", "Feb", ...]
    data:   Array,   // [12000, 18500, ...]
    title:  { type: String, optional: true },
  };

  setup() {
    this.canvasRef = useRef("canvas");
    this._chart = null;

    onMounted(() => {
      this._chart = new Chart(this.canvasRef.el, {
        type: "bar",
        data: {
          labels: this.props.labels,
          datasets: [{
            label: this.props.title ?? "Revenue",
            data: this.props.data,
            backgroundColor: "#2271b1cc",
            borderColor: "#2271b1",
            borderWidth: 1,
            borderRadius: 4,
          }],
        },
        options: {
          responsive: true,
          plugins: { legend: { display: false } },
          scales: {
            y: {
              beginAtZero: true,
              ticks: { callback: v => "$" + (v / 1000).toFixed(0) + "k" },
            },
          },
        },
      });
    });

    onPatched(() => {
      if (!this._chart) return;
      this._chart.data.labels = this.props.labels;
      this._chart.data.datasets[0].data = this.props.data;
      this._chart.update("none");   // "none" = no animation on update
    });

    onWillUnmount(() => {
      this._chart?.destroy();
      this._chart = null;
    });
  }
}
<!-- RevenueChart.xml -->
<templates>
  <t t-name="my_module.RevenueChart">
    <div class="chart-container">
      <h3 class="chart-title" t-esc="props.title or 'Monthly Revenue'" />
      <canvas t-ref="canvas" />
    </div>
  </t>
</templates>
canvas t-ref timing: The canvas DOM element only exists after onMounted fires. Never call new Chart() in setup() or onWillStart() — the canvas isn't in the DOM yet. Always use onMounted for third-party DOM libraries.

Dashboard Root Component

// Dashboard.js
/** @odoo-module **/
import { Component, useState, onWillStart, onMounted, onWillUnmount } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { KpiCard } from "./KpiCard";
import { RevenueChart } from "./RevenueChart";
import { OrdersTable } from "./OrdersTable";

export class Dashboard extends Component {
  static template = "my_module.Dashboard";
  static components = { KpiCard, RevenueChart, OrdersTable };

  setup() {
    this.orm = useService("orm");
    this.notification = useService("notification");

    this.state = useState({
      loading: true,
      error: null,
      period: "month",      // "month" | "quarter" | "year"
      kpi: {
        revenue:    { value: 0, change: null },
        orders:     { value: 0, change: null },
        customers:  { value: 0, change: null },
        avgOrder:   { value: 0, change: null },
      },
      chart: { labels: [], data: [] },
      recentOrders: [],
    });

    this._refreshTimer = null;
    this._abortCtrl = null;

    onWillStart(() => this.loadData());

    onMounted(() => {
      this._refreshTimer = setInterval(() => this.loadData(), 60_000);
    });

    onWillUnmount(() => {
      clearInterval(this._refreshTimer);
      this._abortCtrl?.abort();
    });
  }

  async loadData() {
    this._abortCtrl?.abort();
    this._abortCtrl = new AbortController();
    const signal = this._abortCtrl.signal;

    this.state.loading = true;
    this.state.error = null;

    try {
      const [kpiData, chartData, orders] = await Promise.all([
        this.fetchKpi(this.state.period, signal),
        this.fetchChartData(this.state.period, signal),
        this.fetchRecentOrders(signal),
      ]);

      if (signal.aborted) return;

      this.state.kpi        = kpiData;
      this.state.chart      = chartData;
      this.state.recentOrders = orders;
    } catch (err) {
      if (signal.aborted) return;
      this.state.error = err.message;
    } finally {
      if (!signal.aborted) this.state.loading = false;
    }
  }

  async fetchKpi(period) {
    // Replace with real ORM calls — using readGroup for aggregated data
    const domain = this.periodDomain(period);

    const result = await this.orm.readGroup(
      "sale.order",
      [["state", "=", "sale"], ...domain],
      ["amount_total:sum", "partner_id:count_distinct"],
      [],
    );

    const revenue   = result[0]?.amount_total_sum ?? 0;
    const customers = result[0]?.partner_id_count_distinct ?? 0;
    const orders    = await this.orm.searchCount("sale.order", [["state","=","sale"], ...domain]);

    return {
      revenue:   { value: this.formatCurrency(revenue), change: 12.4 },
      orders:    { value: orders, change: -2.1 },
      customers: { value: customers, change: 8.7 },
      avgOrder:  { value: this.formatCurrency(orders ? revenue / orders : 0), change: 15.2 },
    };
  }

  async fetchChartData(period) {
    // readGroup by month for bar chart
    const domain = this.periodDomain(period);

    const groups = await this.orm.readGroup(
      "sale.order",
      [["state", "=", "sale"], ...domain],
      ["amount_total:sum", "date_order:month"],
      ["date_order:month"],
      { orderby: "date_order ASC" }
    );

    return {
      labels: groups.map(g => g.date_order_month),
      data:   groups.map(g => g.amount_total_sum ?? 0),
    };
  }

  async fetchRecentOrders() {
    return await this.orm.searchRead(
      "sale.order",
      [["state", "in", ["sale", "done", "cancel"]]],
      ["name", "partner_id", "amount_total", "state", "date_order"],
      { order: "date_order DESC", limit: 10 }
    );
  }

  periodDomain(period) {
    const now = new Date();
    if (period === "month") {
      const start = new Date(now.getFullYear(), now.getMonth(), 1);
      return [["date_order", ">=", start.toISOString().slice(0, 10)]];
    }
    if (period === "quarter") {
      const start = new Date(now);
      start.setMonth(start.getMonth() - 3);
      return [["date_order", ">=", start.toISOString().slice(0, 10)]];
    }
    // year
    const start = new Date(now.getFullYear(), 0, 1);
    return [["date_order", ">=", start.toISOString().slice(0, 10)]];
  }

  setPeriod(period) {
    this.state.period = period;
    this.loadData();
  }

  formatCurrency(v) {
    return "$" + Number(v).toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 0 });
  }
}

Dashboard Template

<!-- Dashboard.xml -->
<templates>
  <t t-name="my_module.Dashboard">
    <div class="o-dashboard">

      <div class="dashboard-header">
        <h1>Sales Dashboard</h1>
        <div class="period-selector">
          <button
            t-foreach="[['month','This Month'],['quarter','Last 3 Months'],['year','This Year']]"
            t-as="p"
            t-key="p[0]"
            t-att-class="{ active: state.period === p[0] }"
            t-on-click="() => setPeriod(p[0])"
            t-esc="p[1]"
          />
        </div>
      </div>

      <!-- Error banner -->
      <div t-if="state.error" class="dashboard-error">
        Failed to load data: <t t-esc="state.error" />
        <button t-on-click="loadData">Retry</button>
      </div>

      <!-- Loading skeleton -->
      <t t-if="state.loading">
        <div class="kpi-grid">
          <div t-foreach="[1,2,3,4]" t-as="i" t-key="i" class="kpi-skeleton" />
        </div>
        <div class="chart-skeleton" />
      </t>

      <t t-else="">
        <!-- KPI Grid -->
        <div class="kpi-grid">
          <KpiCard
            label="Total Revenue"
            value="state.kpi.revenue.value"
            change="state.kpi.revenue.change"
            icon="'💰'"
            color="'#00a32a'"
          />
          <KpiCard
            label="Orders"
            value="state.kpi.orders.value"
            change="state.kpi.orders.change"
            icon="'📦'"
            color="'#2271b1'"
          />
          <KpiCard
            label="New Customers"
            value="state.kpi.customers.value"
            change="state.kpi.customers.change"
            icon="'👥'"
            color="'#8b5cf6'"
          />
          <KpiCard
            label="Avg Order Value"
            value="state.kpi.avgOrder.value"
            change="state.kpi.avgOrder.change"
            icon="'📈'"
            color="'#f59e0b'"
          />
        </div>

        <!-- Revenue Chart -->
        <RevenueChart
          labels="state.chart.labels"
          data="state.chart.data"
          title="'Monthly Revenue'"
        />

        <!-- Recent Orders Table -->
        <OrdersTable orders="state.recentOrders" />
      </t>
    </div>
  </t>
</templates>

OrdersTable Component

// OrdersTable.js
/** @odoo-module **/
import { Component } from "@odoo/owl";

export class OrdersTable extends Component {
  static template = "my_module.OrdersTable";
  static props = { orders: Array };

  statusLabel(state) {
    return { sale: "Confirmed", done: "Done", cancel: "Cancelled" }[state] ?? state;
  }
  statusColor(state) {
    return { sale: "#00a32a", done: "#2271b1", cancel: "#d63638" }[state] ?? "#666";
  }
  formatDate(iso) {
    return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
  }
  formatAmount(n) {
    return "$" + Number(n).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
  }
}
<!-- OrdersTable.xml -->
<templates>
  <t t-name="my_module.OrdersTable">
    <div class="orders-table-wrapper">
      <h3>Recent Orders</h3>
      <table class="orders-table">
        <thead>
          <tr>
            <th>Order</th>
            <th>Customer</th>
            <th>Date</th>
            <th>Amount</th>
            <th>Status</th>
          </tr>
        </thead>
        <tbody>
          <tr t-foreach="props.orders" t-as="order" t-key="order.id">
            <td t-esc="order.name" />
            <td t-esc="order.partner_id[1]" />
            <td t-esc="formatDate(order.date_order)" />
            <td t-esc="formatAmount(order.amount_total)" />
            <td>
              <span
                class="status-badge"
                t-att-style="'background:' + statusColor(order.state)"
                t-esc="statusLabel(order.state)"
              />
            </td>
          </tr>
        </tbody>
      </table>
      <div t-if="!props.orders.length" class="empty-orders">No orders found.</div>
    </div>
  </t>
</templates>

Register as Client Action

// index.js
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { Dashboard } from "./dashboard/Dashboard";

registry.category("actions").add("my_module.sales_dashboard", Dashboard);
<!-- data/actions.xml -->
<record id="action_sales_dashboard" model="ir.actions.client">
  <field name="name">Sales Dashboard</field>
  <field name="tag">my_module.sales_dashboard</field>
</record>

<!-- Add to menu -->
<record id="menu_sales_dashboard" model="ir.ui.menu">
  <field name="name">Dashboard</field>
  <field name="parent_id" ref="sale.sale_menu_root" />
  <field name="action" ref="action_sales_dashboard" />
  <field name="sequence">1</field>
</record>

Manifest

# __manifest__.py
{
    "name": "Sales Dashboard",
    "version": "17.0.1.0.0",
    "depends": ["sale", "web"],
    "data": ["data/actions.xml"],
    "assets": {
        "web.assets_backend": [
            # Chart.js CDN or local copy
            "https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js",
            "my_module/static/src/dashboard/KpiCard.js",
            "my_module/static/src/dashboard/KpiCard.xml",
            "my_module/static/src/dashboard/RevenueChart.js",
            "my_module/static/src/dashboard/RevenueChart.xml",
            "my_module/static/src/dashboard/OrdersTable.js",
            "my_module/static/src/dashboard/OrdersTable.xml",
            "my_module/static/src/dashboard/Dashboard.js",
            "my_module/static/src/dashboard/Dashboard.xml",
            "my_module/static/src/index.js",
        ],
    },
}

CSS

.o-dashboard { padding: 24px; }

.dashboard-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 24px;
}

.period-selector button {
  padding: 6px 14px;
  border: 1px solid #ccd0d4;
  background: white;
  cursor: pointer;
  font-size: 13px;
}
.period-selector button:first-child { border-radius: 4px 0 0 4px; }
.period-selector button:last-child  { border-radius: 0 4px 4px 0; }
.period-selector button.active      { background: #2271b1; color: white; border-color: #2271b1; }

.kpi-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 16px;
  margin-bottom: 24px;
}

.kpi-card {
  padding: 20px;
  background: white;
  border-radius: 8px;
  box-shadow: 0 1px 4px rgba(0,0,0,0.1);
  display: flex;
  gap: 12px;
  align-items: flex-start;
}
.kpi-icon   { font-size: 28px; }
.kpi-label  { font-size: 12px; color: #666; margin-bottom: 4px; }
.kpi-value  { font-size: 24px; font-weight: 700; color: #1d2327; }
.kpi-change { font-size: 12px; font-weight: 600; margin-top: 4px; }
.kpi-change.positive { color: #00a32a; }
.kpi-change.negative { color: #d63638; }

.kpi-skeleton {
  height: 100px;
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
  border-radius: 8px;
}
.chart-skeleton {
  height: 280px;
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
  border-radius: 8px;
  margin-bottom: 24px;
}
@keyframes shimmer { to { background-position: -200% 0; } }

.chart-container {
  background: white;
  border-radius: 8px;
  padding: 20px;
  box-shadow: 0 1px 4px rgba(0,0,0,0.1);
  margin-bottom: 24px;
}
.chart-title { font-size: 14px; font-weight: 600; margin-bottom: 12px; }

.orders-table-wrapper {
  background: white;
  border-radius: 8px;
  padding: 20px;
  box-shadow: 0 1px 4px rgba(0,0,0,0.1);
}
.orders-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 13px;
}
.orders-table th {
  text-align: left;
  padding: 8px 12px;
  border-bottom: 2px solid #f0f0f0;
  color: #666;
  font-weight: 600;
}
.orders-table td {
  padding: 10px 12px;
  border-bottom: 1px solid #f8f8f8;
  color: #1d2327;
}
.status-badge {
  display: inline-block;
  padding: 2px 8px;
  border-radius: 10px;
  font-size: 11px;
  font-weight: 600;
  color: white;
}
.dashboard-error {
  padding: 12px 16px;
  background: #fce8e8;
  border: 1px solid #d63638;
  border-radius: 4px;
  margin-bottom: 20px;
  color: #a20e0e;
  font-size: 13px;
  display: flex;
  gap: 12px;
  align-items: center;
}

What You Learned

  • onWillStart + onMounted + onWillUnmount: complete async data lifecycle with cleanup
  • AbortController: cancel previous requests when period changes or component unmounts
  • Chart.js integration: useRef + onMounted to mount, onPatched to update data, onWillUnmount to destroy
  • readGroup aggregation: Odoo ORM readGroup for server-side SUM/COUNT grouping
  • Auto-refresh: setInterval in onMounted, clearInterval in onWillUnmount
  • Loading skeleton: CSS shimmer animation while data loads
  • Client action: registry.category("actions").add() + ir.actions.client record

Extend the Project

  1. Add a product breakdown doughnut chart (top 5 products by revenue) using Chart.js and a second readGroup call grouped by product_id.
  2. Add a "Compare to previous period" toggle that fetches data for both periods and shows a side-by-side grouped bar chart.
  3. Make the refresh interval configurable: add a dropdown (30s / 60s / 5min / Off) stored in a custom user preference via orm.write("res.users", ...).
  4. Add WebSocket-based live updates using Odoo's bus.bus service: push KPI invalidation events from Python when a new order is confirmed, and re-fetch in the OWL component when the event arrives.

FAQ

Why use readGroup instead of searchRead for KPI data?

readGroup runs aggregation (SUM, COUNT, AVG) on the database server side and returns one row per group. For KPI cards you need total revenue across all orders — fetching every order with searchRead and summing in JavaScript would be slow for large datasets and would hit pagination limits. Always push aggregation to the server.

Can I use onWillStart to load data instead of onMounted?

Yes — and for the initial load you should use onWillStart because it blocks rendering until the promise resolves, preventing a flash of empty state. However, you cannot access DOM refs in onWillStart, so Chart.js initialisation must stay in onMounted. The recommended pattern: load data in onWillStart, initialise DOM libraries in onMounted.

How do I show a spinner during reload (not just initial load)?

Set a separate state.reloading flag distinct from state.loading. loading is true only on first render (show skeleton). reloading is true on any subsequent fetch (show a small spinner overlay instead of replacing the entire UI). Reset loading after first successful fetch and never set it to true again.

Does the AbortController actually cancel the ORM request?

In OWL's ORM service layer, AbortController signals can be passed to the underlying fetch if the rpc layer supports it. In current Odoo web, the ORM service does not expose an abort signal parameter on most methods. The pattern here guards on signal.aborted after each await — meaning the request completes but the result is discarded and state is not updated. True network cancellation requires passing the signal through a custom rpc call using the lower-level fetch API.