Este artículo actualmente está disponible solo en inglés. La traducción estará disponible próximamente.
By the end of this recipe, your Odoo 19 sales dashboard will have a custom KPI tile pulling a number from your database, updating live, and clickable to drill into the underlying records. The whole thing fits in roughly 30 lines of code spread across one Python file, one JavaScript file, and one XML file. Skill required: comfortable Odoo developer who has read the OWL framework documentation. Time required: 90 minutes including the boilerplate. ECOSIRE has shipped hundreds of these tiles for paying customers and the recipe below distills the OWL 2 conventions that ship with Odoo 17, 18, and 19.
The reason this matters: the default Odoo dashboards are useful for the first week, then everyone wants their own slice. "Show me MRR but only for active subscriptions". "Average days-to-payment by region". "Inventory at risk in the next 14 days". A custom tile is the lowest-friction way to surface that information in front of the right human at the right moment. We build them for clients in 90 minutes flat, and the implementation pattern below is verbatim what we ship to production.
What you will need
- Odoo version: 17, 18, or 19. The OWL 2 component model is identical across these versions, with a single tweak for v17 noted in the code below.
- Skill: working knowledge of Python decorators (
@api.model), JavaScript ES modules, and OWL 2 (Component,useService,onWillStart). - Module skeleton: a custom module already scaffolded with
__manifest__.py. If you do not have one, runodoo-bin scaffold custom_dashboard /opt/custom-addonsand trim the generated noise. - Test data: at least 10 sales orders in your dev database so the tile has something to display.
- Time: 90 minutes from blank file to live tile in dev. Add 30 minutes for translations and styling polish.
Step-by-step
1. Add the manifest dependency
Edit __manifest__.py to depend on sale (because we are extending its dashboard) and web. You also need to declare both the JavaScript bundle and the QWeb template.
{
'name': 'Custom Sales KPI Tile',
'version': '19.0.1.0.0',
'depends': ['sale', 'web'],
'data': ['views/kpi_views.xml'],
'assets': {
'web.assets_backend': [
'custom_dashboard/static/src/components/sales_kpi.js',
'custom_dashboard/static/src/components/sales_kpi.xml',
'custom_dashboard/static/src/components/sales_kpi.scss',
],
},
'installable': True,
}
Verification: run odoo-bin -u custom_dashboard --stop-after-init and confirm there are no errors in the log. The module loads without doing anything visible yet.
2. Create the Python backend method
Create models/sale_order.py:
from odoo import models, api, fields
from datetime import date, timedelta
class SaleOrder(models.Model):
_inherit = 'sale.order'
@api.model
def get_dashboard_kpi(self):
today = fields.Date.context_today(self)
last_30 = today - timedelta(days=30)
domain = [
('state', 'in', ['sale', 'done']),
('date_order', '>=', last_30),
('company_id', 'in', self.env.companies.ids),
]
orders = self.search(domain)
return {
'count': len(orders),
'total': sum(orders.mapped('amount_total')),
'avg': sum(orders.mapped('amount_total')) / len(orders) if orders else 0,
'currency_symbol': self.env.company.currency_id.symbol,
}
Add from . import sale_order to your models/__init__.py. Verification: open odoo-bin shell and run self.env['sale.order'].get_dashboard_kpi() — it should return a dict with count, total, avg, and currency_symbol. If count is zero, you need more sales orders in your test database.
3. Write the OWL component
Create static/src/components/sales_kpi.js:
/** @odoo-module **/
import { Component, onWillStart, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
export class SalesKpiTile extends Component {
static template = "custom_dashboard.SalesKpiTile";
static props = {};
setup() {
this.orm = useService("orm");
this.action = useService("action");
this.state = useState({ count: 0, total: 0, avg: 0, currency_symbol: "" });
onWillStart(async () => {
const data = await this.orm.call("sale.order", "get_dashboard_kpi", []);
Object.assign(this.state, data);
});
}
onTileClick() {
this.action.doAction({
type: "ir.actions.act_window",
res_model: "sale.order",
views: [[false, "list"], [false, "form"]],
domain: [["state", "in", ["sale", "done"]]],
});
}
}
registry.category("actions").add("custom_dashboard.sales_kpi", SalesKpiTile);
Verification: hit Ctrl+Shift+I, open the browser console, and reload Odoo. You should see no JavaScript errors. The class is registered but nothing renders yet because there is no template.
4. Add the QWeb template
Create static/src/components/sales_kpi.xml:
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="custom_dashboard.SalesKpiTile">
<div class="o_sales_kpi_tile" t-on-click="onTileClick">
<div class="o_kpi_label">Sales (30d)</div>
<div class="o_kpi_value">
<t t-esc="state.currency_symbol"/><t t-esc="state.total.toLocaleString()"/>
</div>
<div class="o_kpi_meta">
<t t-esc="state.count"/> orders · avg <t t-esc="state.currency_symbol"/><t t-esc="Math.round(state.avg)"/>
</div>
</div>
</t>
</templates>
Verification: hard-refresh Odoo (Ctrl+Shift+R) and confirm there are no template parse errors in the browser console.
5. Style it
Create static/src/components/sales_kpi.scss:
.o_sales_kpi_tile {
background: linear-gradient(135deg, #714B67 0%, #875A7B 100%);
color: white;
padding: 24px;
border-radius: 12px;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
min-width: 240px;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0,0,0,0.15);
}
.o_kpi_label { font-size: 14px; opacity: 0.85; }
.o_kpi_value { font-size: 32px; font-weight: 700; margin: 8px 0; }
.o_kpi_meta { font-size: 13px; opacity: 0.85; }
}
Verification: the tile renders with a purple gradient background and the standard Odoo brand color when you trigger it from the URL /odoo/action-custom_dashboard.sales_kpi.
6. Wire it into the sales dashboard
Create views/kpi_views.xml:
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_sales_kpi" model="ir.actions.client">
<field name="name">Sales KPI</field>
<field name="tag">custom_dashboard.sales_kpi</field>
</record>
<menuitem id="menu_sales_kpi"
name="KPI Dashboard"
parent="sale.sale_menu_root"
action="action_sales_kpi"
sequence="5"/>
</odoo>
Verification: upgrade the module (odoo-bin -u custom_dashboard), navigate to Sales > KPI Dashboard, and you should see your purple tile rendering with live numbers.
7. Test the click-through
Click the tile. The Odoo action service should open a list view filtered to confirmed and done sales orders. Verification: the URL bar shows the act_window action and the list shows roughly the count number from the tile.
8. Add a refresh hook for live updates
If you want the tile to refresh every 30 seconds without a page reload, add to the setup() method:
this.refreshInterval = setInterval(async () => {
const data = await this.orm.call("sale.order", "get_dashboard_kpi", []);
Object.assign(this.state, data);
}, 30_000);
// Add cleanup
import { onWillUnmount } from "@odoo/owl";
onWillUnmount(() => clearInterval(this.refreshInterval));
Verification: keep the dashboard open, create a new sales order in another tab, and within 30 seconds the count should bump by 1.
Common mistakes
- Forgetting the
/** @odoo-module **/pragma at the top of the JS file. Without it, OWL silently treats the file as a plain script and your imports break. - Putting the SCSS file in the wrong asset bundle. It must be in
web.assets_backend, notweb.assets_frontend. The frontend is for the public-facing website module. - Calling
useServiceoutside ofsetup(). OWL hooks only work during the setup phase. Calling them in event handlers throws "useService must be called in setup". - Hardcoding the currency. Always pull from
self.env.company.currency_idso multi-currency installations get the right symbol. - Forgetting the multi-company filter
('company_id', 'in', self.env.companies.ids). Without it, your tile leaks data across companies, which is a multi-tenancy bug.
Going further
Once the basic tile works, layer on richer behaviors.
Multi-tile dashboard: build a parent OWL component that holds 4 to 8 KPI tiles in a CSS grid. We use this pattern for executive overview dashboards.
Sparkline charts: add a small inline SVG sparkline using chart.js (already loaded by Odoo Enterprise) showing the trend over the last 14 days. Adds 20 lines of code but transforms the visual impact.
Drill-down with filters: instead of opening the raw list, open a graph view grouped by sales rep or product category. Pass a context like {'group_by': ['user_id'], 'graph_mode': 'bar'} to doAction.
Export to Excel: add an export button that calls a backend method returning a Workbook via xlsxwriter. Most CFOs want to download the underlying data.
If your dashboard requirements get to the level of "real-time multi-source pivot tables with drill-down to account moves", that is where our Odoo customization team comes in. We also have a companion piece on how to integrate Power BI with Odoo for analytics that go beyond what OWL can render.
Frequently Asked Questions
Why use OWL instead of the older Widget framework?
OWL is the official Odoo frontend framework starting in version 14, made mandatory in version 16, and is the only supported option in 17+. It is React-like (with hooks, components, JSX-style templates) but uses XML for templates and tighter integration with the Odoo registry. The old Widget API still loads but is deprecated and removed in v19.
Can the tile be visible to specific user groups only?
Yes. On the ir.actions.client record, set the groups_id field to a Many2many of res.groups (for example sale.group_sale_manager). The menu item itself can also have a groups attribute. Both are evaluated and the tile is hidden from users not in the group.
How do I localize the tile labels?
Wrap the labels in <t t-call="custom_dashboard.MyTile_label_sales"/> and define each label as its own translatable QWeb template, OR use the _t helper imported from @web/core/l10n/translation in the JS file: import { _t } from "@web/core/l10n/translation"; this.label = _t("Sales (30d)");. Then run odoo-bin --i18n-export=fr.po -l fr -d production and translate the resulting PO file.
Will this work in Odoo Studio?
Studio cannot generate OWL components. It can only edit views and add fields. If you need non-developers to build dashboards, point them at the standard Pivot and Graph views which are entirely Studio-editable. OWL components are for engineering-team-built bespoke widgets.
For more advanced custom tile patterns including multi-source data, real-time websocket updates, and mobile-friendly layouts, our Odoo developers can build a complete dashboard on a fixed-price engagement.
Escrito por
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
ECOSIRE
Transforme su negocio con Odoo ERP
Implementación, personalización y soporte experto de Odoo para optimizar sus operaciones.
Artículos relacionados
Cómo agregar un botón personalizado a una vista de formulario de Odoo (2026)
Agregue botones de acción personalizados a las vistas de formulario de Odoo 19: método de acción de Python, herencia de vistas, visibilidad condicional, cuadros de diálogo de confirmación. Probado en producción.
Cómo agregar un campo personalizado en Odoo sin Studio (2026)
Agregue campos personalizados a través de un módulo personalizado en Odoo 19: herencia de modelo, extensión de vista, campos calculados, decisiones de tienda/no tienda. Código primero, controlado por versiones.
Cómo agregar un informe personalizado en Odoo usando un diseño externo
Cree un informe PDF con su marca en Odoo 19 usando web.external_layout: plantilla QWeb, formato de papel, enlace de acción. Con logotipo impreso + anulaciones de pie de página.