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>
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>
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;
}