Skip to content

Building Custom Dashboards in Odoo: Complete Guide

DeployMonkey Team · March 23, 2026 12 min read

Dashboard Approaches in Odoo

Odoo provides several ways to build dashboards, ranging from zero-code to fully custom OWL components:

  1. Stat buttons on forms — quick KPI displays on existing forms
  2. Kanban dashboard views — card-based overview with aggregate data
  3. Custom OWL components — fully custom JavaScript dashboards
  4. Pivot/Graph views — built-in analytical views

Stat Buttons

The simplest dashboard element. Stat buttons sit at the top of form views and show a count or value with an icon:

<div class="oe_button_box" name="button_box">
    <button class="oe_stat_button" icon="fa-envelope"
            name="action_view_messages" type="object">
        <field name="message_count" widget="statinfo" string="Messages"/>
    </button>
    <button class="oe_stat_button" icon="fa-money"
            name="action_view_invoices" type="object">
        <field name="invoice_count" widget="statinfo" string="Invoices"/>
    </button>
    <button class="oe_stat_button" icon="fa-star"
            name="action_view_rating" type="object">
        <field name="avg_rating" widget="statinfo" string="Rating"/>
    </button>
</div>

The Python side defines the computed fields and action methods:

class Partner(models.Model):
    _inherit = 'res.partner'

    invoice_count = fields.Integer(compute='_compute_invoice_count')

    def _compute_invoice_count(self):
        data = self.env['account.move'].read_group(
            [('partner_id', 'in', self.ids), ('move_type', '=', 'out_invoice')],
            ['partner_id'], ['partner_id']
        )
        mapped = {d['partner_id'][0]: d['partner_id_count'] for d in data}
        for partner in self:
            partner.invoice_count = mapped.get(partner.id, 0)

    def action_view_invoices(self):
        return {
            'type': 'ir.actions.act_window',
            'name': 'Invoices',
            'res_model': 'account.move',
            'view_mode': 'tree,form',
            'domain': [('partner_id', '=', self.id), ('move_type', '=', 'out_invoice')],
        }

Kanban Dashboard View

Kanban views with dashboard class provide a card-based overview. This is the pattern used by CRM, Sales, and Accounting dashboards:

<record id="view_sale_dashboard" model="ir.ui.view">
    <field name="name">sale.dashboard</field>
    <field name="model">sale.report</field>
    <field name="arch" type="xml">
        <kanban class="o_kanban_dashboard">
            <templates>
                <t t-name="card">
                    <div class="o_kanban_card_content">
                        <div class="o_kanban_primary_left">
                            <span class="o_value"><t t-esc="record.amount_total.value"/></span>
                            <span class="o_label">Total Revenue</span>
                        </div>
                    </div>
                </t>
            </templates>
        </kanban>
    </field>
</record>

Dashboard Data with read_group

Dashboard controllers typically aggregate data using read_group:

class SaleDashboard(models.Model):
    _name = 'sale.dashboard'
    _description = 'Sales Dashboard'
    _auto = False  # no database table — it is a virtual model

    def _get_dashboard_data(self):
        Order = self.env['sale.order']
        today = fields.Date.today()
        month_start = today.replace(day=1)
        year_start = today.replace(month=1, day=1)

        # This month
        month_data = Order.read_group(
            [('state', '=', 'sale'), ('date_order', '>=', month_start)],
            ['amount_total:sum'], []
        )

        # By salesperson
        by_user = Order.read_group(
            [('state', '=', 'sale'), ('date_order', '>=', year_start)],
            ['amount_total:sum', 'user_id'],
            ['user_id'],
            orderby='amount_total desc',
            limit=5
        )

        # Monthly trend
        trend = Order.read_group(
            [('state', '=', 'sale'), ('date_order', '>=', year_start)],
            ['amount_total:sum', 'date_order'],
            ['date_order:month']
        )

        return {
            'month_total': month_data[0].get('amount_total', 0) if month_data else 0,
            'month_count': month_data[0].get('__count', 0) if month_data else 0,
            'top_salespeople': [{
                'name': d['user_id'][1] if d['user_id'] else 'Unassigned',
                'total': d['amount_total'],
            } for d in by_user],
            'monthly_trend': [{
                'month': d['date_order:month'],
                'total': d['amount_total'],
            } for d in trend],
        }

SQL View for Complex Dashboards

For dashboards requiring joins or complex aggregations, create a SQL view model:

class SaleReport(models.Model):
    _name = 'sale.report.custom'
    _description = 'Custom Sale Report'
    _auto = False  # uses a database VIEW, not a table
    _order = 'date desc'

    date = fields.Date(readonly=True)
    partner_id = fields.Many2one('res.partner', readonly=True)
    user_id = fields.Many2one('res.users', readonly=True)
    amount = fields.Float(readonly=True)
    margin = fields.Float(readonly=True)

    def init(self):
        tools.drop_view_if_exists(self.env.cr, self._table)
        self.env.cr.execute(f"""
            CREATE OR REPLACE VIEW {self._table} AS (
                SELECT
                    so.id AS id,
                    so.date_order::date AS date,
                    so.partner_id,
                    so.user_id,
                    so.amount_total AS amount,
                    so.margin AS margin
                FROM sale_order so
                WHERE so.state = 'sale'
            )
        """)

This model can then use all standard Odoo views — tree, pivot, graph, kanban — and benefits from read_group aggregation.

OWL Component Dashboard

For fully custom dashboards with charts and interactive elements, build an OWL component:

/** @odoo-module **/
import { registry } from "@web/core/registry";
import { Component, onWillStart, useState } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";

class SalesDashboard extends Component {
    static template = "my_module.SalesDashboard";

    setup() {
        this.orm = useService("orm");
        this.state = useState({ data: null, loading: true });

        onWillStart(async () => {
            this.state.data = await this.orm.call(
                "sale.dashboard", "_get_dashboard_data", []
            );
            this.state.loading = false;
        });
    }
}

registry.category("actions").add("sale_custom_dashboard", SalesDashboard);

Register it as a client action:

<record id="action_sale_dashboard" model="ir.actions.client">
    <field name="name">Sales Dashboard</field>
    <field name="tag">sale_custom_dashboard</field>
</record>

Best Practices

  • Start simple: stat buttons and pivot views before building custom OWL dashboards
  • Use read_group for all aggregations — never load individual records for dashboard counts
  • Cache dashboard data if it is expensive to compute — use @ormcache with a short TTL
  • For SQL view models, always use _auto = False and init() with CREATE OR REPLACE VIEW
  • Make dashboard actions accessible via menu items for discoverability
  • Test dashboard performance with production-scale data — read_group on millions of records can still be slow without proper indexes