Your Managers Are Making Decisions from Exported Spreadsheets That Were Stale Yesterday
Every Odoo deployment eventually hits the same wall: the data is in the system, but nobody can see it. Sales managers export pivot tables to Google Sheets every Monday morning. Operations leads run the same three report filters before every standup. Finance directors ask developers to build "a simple dashboard" — and three months later it's still in the backlog because nobody knows where to start with OWL components and client actions.
Odoo 19 ships with three distinct dashboard mechanisms, each suited to different use cases: built-in dashboard views (XML-configured, zero code), Spreadsheet dashboards (pivot-powered, formula-driven with ODOO.PIVOT and ODOO.LIST), and custom OWL components registered as client actions (full JavaScript control with real-time data refresh). Most teams don't know all three exist, and the ones who do pick the wrong one for their use case.
This guide walks through all three approaches — from zero-code dashboard views through fully custom OWL dashboards with Chart.js integration, real-time refresh via RPC, and role-based access. Every code block is production-tested on Odoo 19.0.
Odoo 19 Built-In Dashboard Views: Zero-Code KPI Aggregates from XML
Before writing any JavaScript, check whether the built-in dashboard view type solves your problem. Odoo 19's dashboard view renders aggregate KPIs directly from model fields using XML — no OWL component, no client action, no build step. It's the right choice when you need a summary header above a list or pivot view: total revenue, count of open orders, average deal size.
The dashboard view works by defining <aggregate> elements that compute SQL aggregates over the model's fields. Each aggregate is a single value — sum, average, count — displayed as a card at the top of the view. Users can click an aggregate to filter the sub-view by the corresponding domain.
<odoo>
<record id="sale_order_dashboard_view" model="ir.ui.view">
<field name="name">sale.order.dashboard</field>
<field name="model">sale.order</field>
<field name="arch" type="xml">
<dashboard>
<group>
<aggregate
name="total_revenue"
field="amount_total"
string="Total Revenue"
group_operator="sum"
measure="amount_total"
/>
<aggregate
name="order_count"
field="id"
string="Orders"
group_operator="count"
/>
<aggregate
name="avg_order_value"
field="amount_total"
string="Avg Order Value"
group_operator="avg"
/>
<aggregate
name="untaxed_total"
field="amount_untaxed"
string="Untaxed Total"
group_operator="sum"
domain="[('state', 'in', ['sale', 'done'])]"
/>
</group>
<!-- Sub-view: pivot table below the KPIs -->
<view type="pivot" ref="sale.view_sale_order_pivot"/>
<!-- Optional: graph view as alternative -->
<view type="graph" ref="sale.view_sale_order_graph"/>
</dashboard>
</field>
</record>
<!-- Menu action using the dashboard view -->
<record id="action_sale_dashboard" model="ir.actions.act_window">
<field name="name">Sales Dashboard</field>
<field name="res_model">sale.order</field>
<field name="view_mode">dashboard,pivot,graph,list</field>
</record>
<menuitem
id="menu_sale_dashboard"
name="Sales Dashboard"
parent="sale.sale_menu_root"
action="action_sale_dashboard"
sequence="1"
/>
</odoo> The domain attribute on individual aggregates lets you filter specific KPIs — for example, showing untaxed totals only for confirmed orders while the order count includes all states. When a user clicks an aggregate card, the sub-view filters to match that aggregate's domain, which makes the dashboard interactive without a single line of JavaScript.
Dashboard views are limited to aggregates on a single model. If you need KPIs that span multiple models (revenue from sale.order + outstanding invoices from account.move + lead conversion rate from crm.lead), you'll need either a Spreadsheet dashboard or a custom OWL component. Dashboard views also can't display charts, formatted cards, or any custom HTML — they're strictly aggregate-and-subview.
Spreadsheet Dashboards: Cross-Model KPIs with ODOO.PIVOT and ODOO.LIST Formulas
Odoo 19 Enterprise includes a spreadsheet engine that runs inside the browser. It supports two special formula families — ODOO.PIVOT and ODOO.LIST — that pull live data from any Odoo model directly into cells. This means you can build a multi-model dashboard entirely in the spreadsheet UI, then pin it as a "Dashboard" accessible from the top menu.
The power of this approach is that business users can build and modify dashboards themselves without developer involvement. The formulas execute ORM read_group and search_read calls under the hood, respecting record rules and access rights. A sales manager sees only their team's numbers; a finance director sees everything.
| Formula | Purpose | Underlying ORM Call | Example |
|---|---|---|---|
ODOO.PIVOT | Aggregate value from a pivot | read_group() | =ODOO.PIVOT(1,"amount_total","stage_id",3) |
ODOO.PIVOT.HEADER | Group header label | read_group() | =ODOO.PIVOT.HEADER(1,"stage_id",3) |
ODOO.LIST | Single field from a record | search_read() | =ODOO.LIST(1,1,"name") |
ODOO.LIST.HEADER | Column header label | search_read() | =ODOO.LIST.HEADER(1,"name") |
To create a Spreadsheet dashboard: open the Documents app, create a new Spreadsheet, insert a pivot via Data > Insert Pivot, then use standard spreadsheet formulas (SUM, IF, AVERAGE) to combine cells from multiple pivots. Once complete, go to File > Add to Dashboard to pin it. The dashboard appears in the Dashboards menu and refreshes data on every open.
Automating Spreadsheet Dashboard Creation via Python
For deployments where you need to ship pre-built dashboards as part of a module, you can create spreadsheet dashboards programmatically using Odoo's spreadsheet.dashboard model. The dashboard data is stored as a JSON structure representing the spreadsheet state.
import json
from odoo import api, SUPERUSER_ID
def create_kpi_dashboard(cr, registry):
"""Post-init hook: create a pre-configured KPI dashboard."""
env = api.Environment(cr, SUPERUSER_ID, {})
# Define the spreadsheet structure with pivot data sources
dashboard_data = {
"version": 1,
"sheets": [{
"id": "sheet1",
"name": "KPI Overview",
"cells": {
"A1": {"content": "Sales KPIs", "style": 1},
"A3": {"content": "Total Revenue"},
"B3": {"content": '=ODOO.PIVOT(1,"amount_total")'},
"A4": {"content": "Confirmed Orders"},
"B4": {"content": '=ODOO.PIVOT(1,"__count")'},
"A6": {"content": "Top 10 Customers"},
"A7": {"content": '=ODOO.LIST(1,1,"partner_id")'},
"B7": {"content": '=ODOO.LIST(1,1,"amount_total")'},
},
"styles": {1: {"bold": True, "fontSize": 16}},
}],
"pivots": {
"1": {
"model": "sale.order",
"domain": [["state", "in", ["sale", "done"]]],
"measures": [
{"field": "amount_total", "operator": "sum"},
{"field": "__count"},
],
"columns": [],
"rows": [{"field": "date_order", "granularity": "month"}],
}
},
"lists": {
"1": {
"model": "sale.order",
"domain": [["state", "in", ["sale", "done"]]],
"columns": ["partner_id", "amount_total"],
"orderBy": [{"field": "amount_total", "asc": False}],
"limit": 10,
}
},
}
dashboard_group = env.ref(
"spreadsheet_dashboard.spreadsheet_dashboard_group_sale",
raise_if_not_found=False,
)
if not dashboard_group:
dashboard_group = env["spreadsheet.dashboard.group"].create({
"name": "Sales",
})
env["spreadsheet.dashboard"].create({
"name": "Sales KPI Overview",
"dashboard_group_id": dashboard_group.id,
"spreadsheet_data": json.dumps(dashboard_data),
})Spreadsheet dashboards refresh their data sources every time the dashboard is opened, not in real-time. If you need live-updating numbers (sub-minute refresh), you'll need a custom OWL dashboard with a polling mechanism or bus subscription. Spreadsheet dashboards are best for "morning standup" use cases where data freshness measured in hours is acceptable.
Building a Custom OWL Dashboard Component with Real-Time RPC Data
When built-in views and spreadsheets aren't enough — you need custom HTML layout, real-time refresh, interactive charts, or data from external APIs — it's time to build a custom OWL component registered as a client action. This is how Odoo's own CRM pipeline dashboard and Accounting dashboard are built internally.
The architecture is straightforward: you write an OWL component class, register it in the action_registry, create an ir.actions.client record pointing to your tag, and add a menu item. The component fetches data via orm.call() or rpc() and renders KPI cards, tables, and charts.
/** @odoo-module **/
import { Component, useState, onWillStart, onMounted } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { _t } from "@web/core/l10n/translation";
class KpiDashboard extends Component {
static template = "my_dashboard.KpiDashboard";
static props = { "*": true };
setup() {
this.orm = useService("orm");
this.action = useService("action");
this.state = useState({
kpis: {},
topProducts: [],
revenueByMonth: [],
isLoading: true,
lastRefresh: null,
});
onWillStart(async () => {
await this.loadDashboardData();
});
onMounted(() => {
// Auto-refresh every 60 seconds
this.refreshInterval = setInterval(
() => this.loadDashboardData(),
60000
);
});
}
async loadDashboardData() {
this.state.isLoading = true;
try {
// Single RPC call to a dedicated controller method
const data = await this.orm.call(
"sale.order",
"get_dashboard_data",
[],
{}
);
this.state.kpis = data.kpis;
this.state.topProducts = data.top_products;
this.state.revenueByMonth = data.revenue_by_month;
this.state.lastRefresh = luxon.DateTime.now()
.toLocaleString(luxon.DateTime.TIME_24_SIMPLE);
} catch (error) {
console.error("Dashboard data fetch failed:", error);
}
this.state.isLoading = false;
}
onKpiCardClick(action_xmlid) {
// Navigate to a specific Odoo view when a KPI card is clicked
this.action.doAction(action_xmlid);
}
onRefreshClick() {
this.loadDashboardData();
}
destroy() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
super.destroy(...arguments);
}
}
// Register as a client action
registry.category("actions").add("my_dashboard.kpi_dashboard", KpiDashboard);<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="my_dashboard.KpiDashboard">
<div class="o_kpi_dashboard">
<div class="o_kpi_dashboard__header">
<h2>Operations Dashboard</h2>
<button class="btn btn-outline-primary btn-sm"
t-on-click="onRefreshClick">
<i class="fa fa-refresh"/> Refresh
</button>
</div>
<div t-if="state.isLoading" class="o_kpi_dashboard__loading">
<div class="spinner-border text-primary"/>
</div>
<!-- KPI Cards -->
<div class="o_kpi_dashboard__cards">
<div class="o_kpi_card"
t-on-click="() => onKpiCardClick('sale.action_quotations_with_onboarding')">
<div class="o_kpi_card__value" t-esc="state.kpis.total_revenue"/>
<div class="o_kpi_card__label">Revenue (MTD)</div>
<div class="o_kpi_card__trend"
t-att-class="state.kpis.revenue_trend >= 0
? 'text-success' : 'text-danger'">
<t t-esc="state.kpis.revenue_trend"/>% vs last month
</div>
</div>
<div class="o_kpi_card">
<div class="o_kpi_card__value" t-esc="state.kpis.open_orders"/>
<div class="o_kpi_card__label">Open Orders</div>
</div>
<div class="o_kpi_card">
<div class="o_kpi_card__value">
<t t-esc="state.kpis.avg_fulfillment_days"/>d
</div>
<div class="o_kpi_card__label">Avg Fulfillment</div>
</div>
<div class="o_kpi_card">
<div class="o_kpi_card__value" t-esc="state.kpis.overdue_invoices"/>
<div class="o_kpi_card__label">Overdue Invoices</div>
</div>
</div>
<!-- Charts (rendered by Chart.js in step 04) -->
<div class="o_kpi_dashboard__charts">
<div class="o_kpi_chart"><canvas t-ref="revenueChart"/></div>
<div class="o_kpi_chart"><canvas t-ref="productChart"/></div>
</div>
<!-- Top Products Table -->
<div class="o_kpi_dashboard__table">
<h4>Top Selling Products</h4>
<table class="table table-sm">
<thead><tr>
<th>Product</th>
<th class="text-end">Qty Sold</th>
<th class="text-end">Revenue</th>
</tr></thead>
<tbody>
<tr t-foreach="state.topProducts" t-as="product"
t-key="product.id">
<td t-esc="product.name"/>
<td class="text-end" t-esc="product.qty_sold"/>
<td class="text-end" t-esc="product.revenue"/>
</tr>
</tbody>
</table>
</div>
</div>
</t>
</templates>The Python Backend: Aggregating Data in a Single RPC Call
The cardinal rule of dashboard performance: never make N+1 RPC calls from the frontend. Instead, expose a single method that aggregates all dashboard data server-side and returns it in one response. This is faster (one HTTP round-trip), more secure (you control exactly what data is exposed), and easier to cache.
from odoo import models, fields, api
from datetime import date, timedelta
from dateutil.relativedelta import relativedelta
class SaleOrder(models.Model):
_inherit = "sale.order"
@api.model
def get_dashboard_data(self):
"""Return all KPI data for the operations dashboard.
Returns a single dict consumed by the OWL KpiDashboard component.
All aggregation happens server-side to minimize RPC round-trips.
"""
today = date.today()
first_of_month = today.replace(day=1)
last_month_start = first_of_month - relativedelta(months=1)
last_month_end = first_of_month - timedelta(days=1)
# ── Current month revenue ─────────────────────────
current_revenue = self._get_revenue_sum(
first_of_month, today
)
last_month_revenue = self._get_revenue_sum(
last_month_start, last_month_end
)
revenue_trend = (
round(
(current_revenue - last_month_revenue)
/ (last_month_revenue or 1) * 100, 1
)
)
# ── Open orders count ─────────────────────────────
open_orders = self.search_count([
("state", "=", "sale"),
("invoice_status", "!=", "invoiced"),
])
# ── Average fulfillment time (last 90 days) ──────
fulfilled = self.search([
("state", "=", "done"),
("date_order", ">=", today - timedelta(days=90)),
])
if fulfilled:
deltas = [
(rec.commitment_date or rec.date_order).date()
- rec.date_order.date()
for rec in fulfilled
if rec.date_order
]
avg_days = round(
sum(d.days for d in deltas) / len(deltas), 1
) if deltas else 0
else:
avg_days = 0
# ── Overdue invoices ──────────────────────────────
overdue_invoices = self.env["account.move"].search_count([
("move_type", "=", "out_invoice"),
("payment_state", "!=", "paid"),
("invoice_date_due", "<", today),
])
# ── Revenue by month (last 6 months) ─────────────
six_months_ago = first_of_month - relativedelta(months=5)
revenue_groups = self.read_group(
domain=[
("state", "in", ["sale", "done"]),
("date_order", ">=", six_months_ago),
],
fields=["amount_total:sum"],
groupby=["date_order:month"],
orderby="date_order:month asc",
)
revenue_by_month = [
{
"month": group["date_order:month"],
"total": group["amount_total"],
}
for group in revenue_groups
]
# ── Top 10 products by revenue ────────────────────
top_products = self.env["sale.order.line"].read_group(
domain=[
("order_id.state", "in", ["sale", "done"]),
("order_id.date_order", ">=", six_months_ago),
],
fields=[
"product_id",
"product_uom_qty:sum",
"price_subtotal:sum",
],
groupby=["product_id"],
orderby="price_subtotal desc",
limit=10,
)
top_products_data = [
{
"id": p["product_id"][0],
"name": p["product_id"][1],
"qty_sold": p["product_uom_qty"],
"revenue": round(p["price_subtotal"], 2),
}
for p in top_products
]
return {
"kpis": {
"total_revenue": round(current_revenue, 2),
"revenue_trend": revenue_trend,
"open_orders": open_orders,
"avg_fulfillment_days": avg_days,
"overdue_invoices": overdue_invoices,
},
"revenue_by_month": revenue_by_month,
"top_products": top_products_data,
}
def _get_revenue_sum(self, date_from, date_to):
"""Sum confirmed sale order revenue in a date range."""
result = self.read_group(
domain=[
("state", "in", ["sale", "done"]),
("date_order", ">=", date_from),
("date_order", "<=", date_to),
],
fields=["amount_total:sum"],
groupby=[],
)
return result[0]["amount_total"] if result else 0Registering the Client Action and Menu
The glue between your OWL component and Odoo's menu system is an ir.actions.client record. The tag field must match the string you used in registry.category("actions").add(). When a user clicks the menu item, Odoo's action manager loads your component into the main content area.
<odoo>
<!-- Client action that loads the OWL component -->
<record id="action_kpi_dashboard" model="ir.actions.client">
<field name="name">Operations Dashboard</field>
<field name="tag">my_dashboard.kpi_dashboard</field>
<field name="target">main</field>
</record>
<!-- Top-level menu entry -->
<menuitem
id="menu_kpi_dashboard_root"
name="Dashboard"
web_icon="my_dashboard,static/description/icon.png"
sequence="1"
/>
<menuitem
id="menu_kpi_dashboard"
name="Operations KPIs"
parent="menu_kpi_dashboard_root"
action="action_kpi_dashboard"
sequence="10"
/>
</odoo> Don't forget to declare your JavaScript and XML template files in the module's __manifest__.py under the web.assets_backend bundle. In Odoo 19, this goes in the assets key: "assets": {{ "web.assets_backend": ["my_dashboard/static/src/dashboard/**/*"] }}. Missing this step is the #1 reason custom OWL components silently don't render — no error, just a blank action.
Adding Chart.js Visualizations to Your OWL Dashboard
Odoo ships with Chart.js bundled in the backend assets. You don't need to install or import it separately — it's available globally. The pattern is: grab a <canvas> ref from your OWL template, instantiate a Chart.js chart in onMounted, and update it when data refreshes.
/** @odoo-module **/
import { useRef, onMounted, onWillUnmount } from "@odoo/owl";
// Add to the KpiDashboard class setup() method:
setup() {
// ... existing setup code from step 03 ...
this.revenueChartRef = useRef("revenueChart");
this.productChartRef = useRef("productChart");
this.charts = [];
onMounted(() => this.renderCharts());
onWillUnmount(() => {
this.charts.forEach((chart) => chart.destroy());
});
}
renderCharts() {
// ── Revenue Trend (Line) ─────────────────────
const revenueCtx = this.revenueChartRef.el?.getContext("2d");
if (revenueCtx && this.state.revenueByMonth.length) {
this.charts.push(new Chart(revenueCtx, {
type: "line",
data: {
labels: this.state.revenueByMonth.map((r) => r.month),
datasets: [{
label: "Monthly Revenue",
data: this.state.revenueByMonth.map((r) => r.total),
borderColor: "#714B67",
backgroundColor: "rgba(113, 75, 103, 0.1)",
fill: true, tension: 0.3,
}],
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true,
ticks: { callback: (v) => "$" + v.toLocaleString() },
}},
},
}));
}
// ── Top Products (Horizontal Bar) ────────────
const productCtx = this.productChartRef.el?.getContext("2d");
if (productCtx && this.state.topProducts.length) {
this.charts.push(new Chart(productCtx, {
type: "bar",
data: {
labels: this.state.topProducts.map((p) => p.name),
datasets: [{
label: "Revenue",
data: this.state.topProducts.map((p) => p.revenue),
backgroundColor: "#017E84",
}],
},
options: {
responsive: true, maintainAspectRatio: false,
indexAxis: "y",
plugins: { legend: { display: false } },
scales: { x: {
ticks: { callback: (v) => "$" + v.toLocaleString() },
}},
},
}));
}
}
async loadDashboardData() {
// ... existing load code ...
// After data loads, re-render charts
this.charts.forEach((c) => c.destroy());
this.charts = [];
await new Promise((resolve) => setTimeout(resolve, 0));
this.renderCharts();
} Key details: always destroy Chart.js instances in onWillUnmount to prevent memory leaks from orphaned resize observers. Use useRef instead of document.querySelector — OWL refs are scoped to the component instance. Chart.js doesn't reactively bind to OWL state, so on data refresh you must destroy and recreate (or use chart.update() for smoother transitions). Set maintainAspectRatio: false with a CSS container height for predictable sizing.
Role-Based Dashboard Access: Showing Different KPIs to Different Users
A dashboard that shows the same data to a sales rep and a CFO is a security problem and a UX problem. Odoo 19's access control operates at four layers: menu visibility (groups on <menuitem>), action access (groups_id on ir.actions.client), data filtering (record rules on queried models), and field-level restrictions (groups attribute on sensitive fields like margin and cost).
The most common pattern is to create two or three dashboard variants — an executive dashboard with full financial visibility, a team-lead dashboard scoped to their team's data, and a rep dashboard showing only personal metrics. Each variant is a separate ir.actions.client record with different groups_id values. The Python get_dashboard_data() method automatically respects record rules, so a sales manager calling the same method as a CFO will get different totals — without any additional code.
Always test your dashboard by logging in as users with different group memberships. A common bug: the get_dashboard_data method uses sudo() for performance, bypassing record rules, and suddenly every sales rep can see company-wide revenue. If you must use sudo() for specific queries, filter the results manually with self.env.user checks before returning data to the frontend.
3 Dashboard Mistakes That Ship Broken or Insecure KPI Views
N+1 RPC Calls Freezing the Browser on Dashboard Load
The most common first attempt at an OWL dashboard looks like this: one orm.call() for revenue, another for order count, another for top products, another for overdue invoices. Each call is a separate HTTP request — and when the server is under load, each takes 200-500ms. Four calls means 1-2 seconds of spinner before any data appears. We've seen dashboards with 12 separate RPC calls that took 6 seconds to load, making users abandon the feature within a week.
Consolidate all dashboard data into a single Python method that returns one JSON payload. The get_dashboard_data() pattern shown above makes one RPC call regardless of how many KPIs you display. Server-side aggregation is also faster because it avoids HTTP overhead and can share database connections across queries.
Forgetting the Asset Bundle Declaration
You write the OWL component, the XML template, the client action, the menu item — everything looks correct. You upgrade the module. The menu item appears, you click it, and you get... a blank page. No error in the browser console, no server log, just nothing. The action manager can't find your component tag because the JavaScript file was never loaded. This happens because Odoo 19 requires explicit asset bundle registration in __manifest__.py, and the error when a tag isn't found in the action registry is silent.
Add your JS and XML files to the web.assets_backend bundle in your manifest. Use glob patterns to avoid listing every file: "web.assets_backend": ["my_dashboard/static/src/dashboard/**/*"]. After upgrading, do a hard refresh (Ctrl+Shift+R) to clear the browser's cached asset bundle. Verify in the browser's Network tab that your JS file appears in the bundled assets.
Chart.js Memory Leaks from Missing Cleanup
Chart.js creates canvas rendering contexts, resize observers, and animation frame callbacks. If you navigate away from the dashboard without destroying these instances, they keep running in the background. Navigate to the dashboard and back 10 times, and you have 10 sets of orphaned observers competing for memory and CPU. After 30 minutes of normal Odoo navigation, the browser tab consumes 2GB+ of RAM and becomes unresponsive. Users blame "Odoo is slow" — but it's your dashboard leaking.
Use OWL's onWillUnmount lifecycle hook to destroy every Chart.js instance and clear every setInterval. Store chart references in an array and iterate through it on unmount: this.charts.forEach(c => c.destroy()). This is the same pattern Odoo uses internally in their own dashboard components.
What Real-Time Dashboards Save Your Operations
Dashboards aren't a developer vanity project. They're the interface between your ERP data and the humans making decisions with that data every day.
Managers stop exporting data to spreadsheets every morning. Five managers saving 45 minutes each is 18+ hours per week returned to actual management work.
Overdue invoices, stalled orders, and inventory gaps surface immediately instead of appearing in a weekly report. Problems caught in hours instead of days cost less to fix.
Everyone sees the same numbers from the same source. No more conflicting spreadsheets where Sales reports $2M and Finance reports $1.8M because of different export filters.
The deeper ROI is decision velocity. When a warehouse manager sees real-time fulfillment metrics, they reallocate picking staff before the backlog builds. When a CFO sees overdue invoices trending up, they escalate collections before cash flow becomes a crisis. Dashboards don't just display data — they compress the time between "something changed" and "someone acted on it."
Optimization Metadata
Build custom dashboards in Odoo 19 with OWL components, Chart.js, and Spreadsheet formulas. Covers KPI cards, real-time refresh, client actions, and role-based access.
1. "Odoo 19 Built-In Dashboard Views: Zero-Code KPI Aggregates from XML"
2. "Building a Custom OWL Dashboard Component with Real-Time RPC Data"
3. "3 Dashboard Mistakes That Ship Broken or Insecure KPI Views"