Quick Answer
Standard Odoo dashboards look like Excel—static, boring, uninspiring. Build custom OWL (Odoo Web Library) widgets that update in real-time: revenue speedometers, product leaderboards, warehouse heatmaps. Result: A dashboard that looks like a SaaS product, not an ERP. Executives get excited. Teams engage. The visual layer is the difference between "I guess we'll use this" and "This is the brain of our company."
The Dashboard Problem
Your CEO wants a dashboard. Not just a list of numbers, but a visual command center.
They ask for:
❏ A "Daily Revenue Speedometer" that updates in real-time
❏ A "Top Selling Products" leaderboard with images
❏ A "Warehouse Heatmap" showing bin utilization
The "Standard" Way (Spreadsheet Dashboard)
You use the standard Odoo Spreadsheet or Pivot views. It works, but it's static. It looks like Excel. It doesn't scream "modern command center," and it requires clicks to refresh.
The "Pro" Way (Custom JavaScript Widgets)
You build fully interactive, React-like components using Odoo's OWL (Odoo Web Library) framework.
Result: A dashboard that looks like a SaaS product. Live data. Custom animations. Drag-and-drop interactivity.
We've implemented 150+ Odoo systems. The ones that get executives excited? They don't look like ERPs. They look like bespoke apps. That visual layer is often the difference between "I guess we'll use this system" and "This is the brain of our company."
The Architecture: OWL (Odoo Web Library)
Since Odoo 14 (and fully in 16+), the frontend is built on OWL. It is a modern component framework inspired by React and Vue.
The Recipe for a Widget
✓ The Template (XML): Defines the HTML structure
✓ The Component (JS): Defines the state, logic, and hooks
✓ The Loader (JS/XML): Registers the component to be used in Dashboards
Step 1: The Component Logic (JavaScript)
We will build a "Daily Sales Target" widget that shows a progress bar and changes color based on performance.
/** @odoo-module */
import { Component, onWillStart, useState } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export class SalesTargetCard extends Component {
setup() {
// 1. Setup State (Reactive data)
this.state = useState({
currentSales: 0,
target: 10000,
percentage: 0,
loading: true,
});
// 2. Get Odoo RPC Service to talk to backend
this.orm = useService("orm");
// 3. Load data before component mounts
onWillStart(async () => {
await this.loadData();
});
}
async loadData() {
// Call a Python method in 'sale.order' model
const result = await this.orm.call(
"sale.order",
"get_daily_sales_stats",
[]
);
this.state.currentSales = result.total;
this.state.target = result.target;
this.state.percentage = Math.min(
Math.round((result.total / result.target) * 100),
100
);
this.state.loading = false;
}
get colorClass() {
if (this.state.percentage < 50) return "text-danger";
if (this.state.percentage < 80) return "text-warning";
return "text-success";
}
}
// Define the template name (we'll create this next)
SalesTargetCard.template = "my_module.SalesTargetCard";
Step 2: The Visuals (XML Template)
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="my_module.SalesTargetCard" owl="1">
<div class="card shadow-sm h-100">
<div class="card-body">
<h5 class="card-title text-muted mb-3">Today's Revenue</h5>
<t t-if="state.loading">
<div class="spinner-border text-primary" role="status"/>
</t>
<t t-else="">
<h2 t-att-class="colorClass">
$<t t-esc="state.currentSales.toLocaleString()"/>
</h2>
<div class="progress mt-3" style="height: 10px;">
<div class="progress-bar"
role="progressbar"
t-att-style="'width: ' + state.percentage + '%'"
t-att-class="'bg-' + (state.percentage >= 80 ? 'success' : 'warning')">
</div>
</div>
<small class="text-muted mt-2 d-block">
Target: $<t t-esc="state.target.toLocaleString()"/>
(<t t-esc="state.percentage"/>%)
</small>
</t>
</div>
</div>
</t>
</templates>
Step 3: Registering with the Dashboard Module
Now we need to tell Odoo, "Hey, this widget exists, and users should be able to add it to their dashboards."
Method A: Add to the System Tray (Menu Bar)
This is great for global KPIs visible on every screen.
import { registry } from "@web/core/registry";
import { SalesTargetCard } from "../components/sales_target_card/sales_target_card";
// Add to the 'systray' category
registry.category("systray").add("SalesTargetWidget", {
Component: SalesTargetCard,
});
Method B: Add to a Dashboard View (Client Action)
If you are building a full "Sales Dashboard" page.
Define Client Action (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>
Register the Tag (JavaScript):
import { registry } from "@web/core/registry";
import { SalesTargetCard } from "./sales_target_card";
import { Component } from "@odoo/owl";
class SalesDashboard extends Component {
static template = xml`
<div class="o_dashboard container-fluid p-4">
<div class="row">
<div class="col-md-4">
<SalesTargetCard/>
</div>
<!-- Add other widgets here -->
</div>
</div>
`;
static components = { SalesTargetCard };
}
registry.category("actions").add("my_module.sales_dashboard", SalesDashboard);
Step 4: The Backend Logic (Python)
The frontend loadData() call needs a Python method to talk to.
from odoo import models, api, fields
from datetime import date
class SaleOrder(models.Model):
_inherit = 'sale.order'
@api.model
def get_daily_sales_stats(self):
"""
Returns JSON data for the dashboard widget.
"""
today = date.today()
# 1. Calculate Today's Sales
domain = [
('date_order', '>=', today),
('state', 'in', ['sale', 'done'])
]
orders = self.search(domain)
total_sales = sum(orders.mapped('amount_total'))
# 2. Get Target (e.g., from Company Settings)
# Assuming you added a field 'daily_sales_target' to res.company
target = self.env.company.daily_sales_target or 10000
return {
'total': total_sales,
'target': target,
}
Best Practices for High-Performance Widgets
1. Don't Over-Fetch
❌ The Mistake:
Returning self.search_read([]) to the frontend and letting JavaScript calculate the totals.
✓ The Fix:
Do the heavy lifting (aggregations, sums, groupings) in Python/SQL. Send only the final numbers to the frontend.
2. Use onWillStart vs onMounted
✓ Use onWillStart to fetch data before the component renders (prevents layout shift)
✓ Use onMounted for things that require the DOM elements to exist (like rendering a Chart.js graph inside a <div>)
3. Auto-Refresh (Live Data)
To make it "Live," use setInterval inside setup.
setup() {
// ... setup state ...
// Refresh every 60 seconds
const interval = setInterval(() => this.loadData(), 60000);
// CLEANUP: Important! Stop the timer when the widget is destroyed/closed
onWillDestroy(() => clearInterval(interval));
}
Real-World D2C Scenario: The "Picking Wave" Widget
Problem: Warehouse managers didn't know how many orders were "Ready to Pick" versus "Blocked" without refreshing a list view.
Solution: We built a "Traffic Light" widget.
🟢 Green Circle: < 50 orders waiting
🟡 Yellow Circle: 50-100 orders waiting
🔴 Red Flashing Circle: > 100 orders waiting (Bottleneck Alert)
The widget sat in the top menu bar. Managers saw the bottleneck immediately and reallocated staff instantly.
Result: 20% reduction in late shipments.
Widget Development Workflow
| Step | Component | File Location |
|---|---|---|
| 1. JavaScript Logic | OWL Component with state & hooks | static/src/components/*.js |
| 2. XML Template | HTML structure with QWeb | static/src/components/*.xml |
| 3. Python Backend | API endpoint for data | models/*.py |
| 4. Registration | Register in systray or actions | static/src/*/registry.js |
Action Items: Build Your Dashboard Widget
Concept
❏ Sketch the dashboard on paper. What exact numbers does the CEO need?
❏ Identify the backend models (Sales, Inventory, Invoices) needed to get those numbers
Code
❏ Create the OWL component skeleton (.js and .xml)
❏ Write the Python @api.model method to supply the data
❏ Register the component in the actions registry
❏ Add my_module/static/src/**/* to your manifest's assets -> web.assets_backend
Refine
❏ Add a loading spinner
❏ Add error handling (what if the server is offline?)
❏ Add auto-refresh logic
Frequently Asked Questions
What's the difference between OWL and standard Odoo JavaScript?
OWL is Odoo's modern component framework (like React/Vue) introduced in Odoo 14+. It uses reactive state, hooks, and component lifecycle methods. Standard JavaScript was the old approach (pre-v14) using jQuery and manual DOM manipulation. Use OWL for all new development.
Can I use Chart.js or other JavaScript libraries in OWL widgets?
Yes. Use onMounted hook to initialize Chart.js after the DOM element exists. Import the library in your component and render it to a <canvas> element. Make sure to add Chart.js to your module's assets in the manifest.
How do I make dashboard widgets update in real-time?
Use setInterval(() => this.loadData(), 60000) inside setup() to refresh every 60 seconds. Critical: Use onWillDestroy(() => clearInterval(interval)) to cleanup, or you'll create memory leaks.
Should I calculate totals in JavaScript or Python?
Always Python. Do aggregations, sums, and groupings in Python/SQL. Only send final numbers to the frontend. Fetching 1000 records to JavaScript and calculating totals client-side is slow and wasteful. Python handles it in milliseconds.
Free Custom Dashboard Workshop
Stop staring at boring list views. We'll review your key metrics, prototype a high-impact dashboard layout, provide the OWL boilerplate code for your specific widgets, and show you how to embed Chart.js or Recharts for beautiful graphs. Turn your ERP into a Command Center.
