feat: add WooCommerce sync dashboard widget
- WooDashboard OWL client action registered as fusion_woocommerce.woo_dashboard - Cards: pending orders (clickable), last sync (relative time), errors 24h (clickable), products mapped % with progress bar - Quick actions: Sync Now, View Conflicts, Open Mapping, View Orders - Instances table showing name, connection state, last sync datetime - Auto-refreshes every 60 s while mounted - All data fetched via standalone rpc() per Odoo 19 rules Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
221
fusion-woo-odoo/fusion_woocommerce/static/src/js/dashboard.js
Normal file
221
fusion-woo-odoo/fusion_woocommerce/static/src/js/dashboard.js
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { Component, useState, onWillStart, onMounted } from "@odoo/owl";
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
import { rpc } from "@web/core/network/rpc";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WooDashboard — OWL client action for the WooCommerce sync dashboard.
|
||||||
|
*
|
||||||
|
* Shows:
|
||||||
|
* - Orders pending sync
|
||||||
|
* - Last sync time (relative)
|
||||||
|
* - Errors in last 24 h
|
||||||
|
* - Products mapped / unmapped (progress bar)
|
||||||
|
* - Quick actions: Sync Now, View Conflicts, Open Mapping
|
||||||
|
*/
|
||||||
|
export class WooDashboard extends Component {
|
||||||
|
static template = "fusion_woocommerce.Dashboard";
|
||||||
|
static props = ["action", "*"];
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.actionService = useService("action");
|
||||||
|
this.notification = useService("notification");
|
||||||
|
|
||||||
|
this.state = useState({
|
||||||
|
loading: true,
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
pendingOrders: 0,
|
||||||
|
lastSync: null,
|
||||||
|
errors24h: 0,
|
||||||
|
mappedCount: 0,
|
||||||
|
totalProducts: 0,
|
||||||
|
|
||||||
|
// Instances
|
||||||
|
instances: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
onWillStart(async () => {
|
||||||
|
await this._loadDashboard();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh every 60 s while mounted
|
||||||
|
this._refreshInterval = null;
|
||||||
|
onMounted(() => {
|
||||||
|
this._refreshInterval = setInterval(() => {
|
||||||
|
this._loadDashboard();
|
||||||
|
}, 60000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Data
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async _loadDashboard() {
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
this._loadInstances(),
|
||||||
|
this._loadPendingOrders(),
|
||||||
|
this._loadErrors(),
|
||||||
|
this._loadProductStats(),
|
||||||
|
this._loadLastSync(),
|
||||||
|
]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[WooDashboard] _loadDashboard error:", err);
|
||||||
|
} finally {
|
||||||
|
this.state.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _loadInstances() {
|
||||||
|
const result = await rpc("/web/dataset/call_kw", {
|
||||||
|
model: "woo.instance",
|
||||||
|
method: "search_read",
|
||||||
|
args: [[]],
|
||||||
|
kwargs: { fields: ["id", "name", "state", "last_sync"], limit: 20 },
|
||||||
|
});
|
||||||
|
this.state.instances = result || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async _loadPendingOrders() {
|
||||||
|
const count = await rpc("/web/dataset/call_kw", {
|
||||||
|
model: "woo.order",
|
||||||
|
method: "search_count",
|
||||||
|
args: [[["state", "in", ["new", "confirmed"]]]],
|
||||||
|
kwargs: {},
|
||||||
|
});
|
||||||
|
this.state.pendingOrders = count || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _loadErrors() {
|
||||||
|
// Errors from woo.sync.log in the last 24 h
|
||||||
|
const since = new Date(Date.now() - 24 * 3600 * 1000);
|
||||||
|
const sinceStr = since.toISOString().replace("T", " ").substring(0, 19);
|
||||||
|
|
||||||
|
const count = await rpc("/web/dataset/call_kw", {
|
||||||
|
model: "woo.sync.log",
|
||||||
|
method: "search_count",
|
||||||
|
args: [[
|
||||||
|
["state", "=", "failed"],
|
||||||
|
["create_date", ">=", sinceStr],
|
||||||
|
]],
|
||||||
|
kwargs: {},
|
||||||
|
});
|
||||||
|
this.state.errors24h = count || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _loadProductStats() {
|
||||||
|
const [mapped, total] = await Promise.all([
|
||||||
|
rpc("/web/dataset/call_kw", {
|
||||||
|
model: "woo.product.map",
|
||||||
|
method: "search_count",
|
||||||
|
args: [[["state", "=", "mapped"]]],
|
||||||
|
kwargs: {},
|
||||||
|
}),
|
||||||
|
rpc("/web/dataset/call_kw", {
|
||||||
|
model: "woo.product.map",
|
||||||
|
method: "search_count",
|
||||||
|
args: [[]],
|
||||||
|
kwargs: {},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
this.state.mappedCount = mapped || 0;
|
||||||
|
this.state.totalProducts = total || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _loadLastSync() {
|
||||||
|
// Get the most recent successful sync log entry
|
||||||
|
const result = await rpc("/web/dataset/call_kw", {
|
||||||
|
model: "woo.sync.log",
|
||||||
|
method: "search_read",
|
||||||
|
args: [[["state", "=", "success"]]],
|
||||||
|
kwargs: {
|
||||||
|
fields: ["create_date"],
|
||||||
|
limit: 1,
|
||||||
|
order: "create_date desc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (result && result.length) {
|
||||||
|
this.state.lastSync = result[0].create_date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Computed / helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
get mappedPercent() {
|
||||||
|
if (!this.state.totalProducts) return 0;
|
||||||
|
return Math.round((this.state.mappedCount / this.state.totalProducts) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
get lastSyncRelative() {
|
||||||
|
if (!this.state.lastSync) return "Never";
|
||||||
|
const past = new Date(this.state.lastSync.replace(" ", "T") + "Z");
|
||||||
|
const diffMs = Date.now() - past.getTime();
|
||||||
|
const diffMin = Math.floor(diffMs / 60000);
|
||||||
|
|
||||||
|
if (diffMin < 1) return "Just now";
|
||||||
|
if (diffMin < 60) return `${diffMin} minute${diffMin > 1 ? "s" : ""} ago`;
|
||||||
|
const diffHr = Math.floor(diffMin / 60);
|
||||||
|
if (diffHr < 24) return `${diffHr} hour${diffHr > 1 ? "s" : ""} ago`;
|
||||||
|
const diffDay = Math.floor(diffHr / 24);
|
||||||
|
return `${diffDay} day${diffDay > 1 ? "s" : ""} ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Actions
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async syncNow() {
|
||||||
|
try {
|
||||||
|
const instanceIds = this.state.instances.map((i) => i.id);
|
||||||
|
if (!instanceIds.length) {
|
||||||
|
this.notification.add("No WooCommerce instances configured.", { type: "warning" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await rpc("/web/dataset/call_kw", {
|
||||||
|
model: "woo.instance",
|
||||||
|
method: "action_sync",
|
||||||
|
args: [instanceIds],
|
||||||
|
kwargs: {},
|
||||||
|
});
|
||||||
|
this.notification.add("Sync started for all instances.", { type: "success" });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[WooDashboard] syncNow error:", err);
|
||||||
|
this.notification.add("Failed to start sync.", { type: "danger" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openOrders() {
|
||||||
|
this.actionService.doAction("fusion_woocommerce.action_woo_order");
|
||||||
|
}
|
||||||
|
|
||||||
|
openSyncLogs() {
|
||||||
|
this.actionService.doAction({
|
||||||
|
type: "ir.actions.act_window",
|
||||||
|
name: "Sync Errors (Last 24 h)",
|
||||||
|
res_model: "woo.sync.log",
|
||||||
|
view_mode: "tree,form",
|
||||||
|
domain: [["state", "=", "failed"]],
|
||||||
|
target: "current",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openConflicts() {
|
||||||
|
this.actionService.doAction("fusion_woocommerce.action_woo_conflict");
|
||||||
|
}
|
||||||
|
|
||||||
|
openMapping() {
|
||||||
|
this.actionService.doAction({
|
||||||
|
type: "ir.actions.client",
|
||||||
|
tag: "fusion_woocommerce.product_mapping",
|
||||||
|
name: "Product Mapping",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.category("actions").add("fusion_woocommerce.woo_dashboard", WooDashboard);
|
||||||
142
fusion-woo-odoo/fusion_woocommerce/static/src/xml/dashboard.xml
Normal file
142
fusion-woo-odoo/fusion_woocommerce/static/src/xml/dashboard.xml
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
|
<t t-name="fusion_woocommerce.Dashboard">
|
||||||
|
<div class="o_action o_client_action woo-dashboard">
|
||||||
|
|
||||||
|
<div class="woo-dashboard-title">WooCommerce Dashboard</div>
|
||||||
|
<div class="woo-dashboard-subtitle">
|
||||||
|
At-a-glance sync status across all WooCommerce instances.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<t t-if="state.loading">
|
||||||
|
<div class="woo-loading">
|
||||||
|
<div class="woo-spinner"/>
|
||||||
|
Loading dashboard…
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<t t-else="">
|
||||||
|
|
||||||
|
<!-- Stat cards -->
|
||||||
|
<div class="woo-cards">
|
||||||
|
|
||||||
|
<!-- Pending orders -->
|
||||||
|
<div class="woo-card woo-card-pending woo-card-clickable"
|
||||||
|
t-on-click="openOrders">
|
||||||
|
<div class="woo-card-icon">🛒</div>
|
||||||
|
<div class="woo-card-value" t-esc="state.pendingOrders"/>
|
||||||
|
<div class="woo-card-label">Orders Pending Sync</div>
|
||||||
|
<div class="woo-card-sub">Click to view orders</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Last sync -->
|
||||||
|
<div class="woo-card woo-card-sync">
|
||||||
|
<div class="woo-card-icon">🔄</div>
|
||||||
|
<div class="woo-card-value" style="font-size:1.1rem;" t-esc="lastSyncRelative"/>
|
||||||
|
<div class="woo-card-label">Last Sync</div>
|
||||||
|
<div class="woo-card-sub">
|
||||||
|
<t t-if="state.instances.length">
|
||||||
|
<t t-esc="state.instances.length"/> instance<t t-if="state.instances.length !== 1">s</t> configured
|
||||||
|
</t>
|
||||||
|
<t t-else="">No instances configured</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Errors -->
|
||||||
|
<div class="woo-card woo-card-errors woo-card-clickable"
|
||||||
|
t-on-click="openSyncLogs">
|
||||||
|
<div class="woo-card-icon">⚠️</div>
|
||||||
|
<div class="woo-card-value" t-esc="state.errors24h"/>
|
||||||
|
<div class="woo-card-label">Errors (Last 24 h)</div>
|
||||||
|
<div class="woo-card-sub">Click to view sync log</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Products mapped -->
|
||||||
|
<div class="woo-card woo-card-mapped">
|
||||||
|
<div class="woo-card-icon">🔗</div>
|
||||||
|
<div class="woo-card-value">
|
||||||
|
<t t-esc="mappedPercent"/>%
|
||||||
|
</div>
|
||||||
|
<div class="woo-card-label">Products Mapped</div>
|
||||||
|
<div class="woo-progress-wrap">
|
||||||
|
<div class="woo-progress-bar"
|
||||||
|
t-att-style="'width:' + mappedPercent + '%'"/>
|
||||||
|
</div>
|
||||||
|
<div class="woo-card-sub">
|
||||||
|
<t t-esc="state.mappedCount"/> / <t t-esc="state.totalProducts"/> products
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick actions -->
|
||||||
|
<div class="woo-section-title">Quick Actions</div>
|
||||||
|
<div class="woo-quick-actions">
|
||||||
|
|
||||||
|
<button class="woo-btn woo-btn-primary" t-on-click="syncNow">
|
||||||
|
<i class="fa fa-refresh me-1"/> Sync Now
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="woo-btn woo-btn-warning" t-on-click="openConflicts">
|
||||||
|
<i class="fa fa-exclamation-triangle me-1"/> View Conflicts
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="woo-btn woo-btn-secondary" t-on-click="openMapping">
|
||||||
|
<i class="fa fa-th-list me-1"/> Open Product Mapping
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="woo-btn woo-btn-secondary" t-on-click="openOrders">
|
||||||
|
<i class="fa fa-shopping-cart me-1"/> View Orders
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Instances table (if any) -->
|
||||||
|
<t t-if="state.instances.length">
|
||||||
|
<div class="woo-section-title mt-4">Instances</div>
|
||||||
|
<div class="woo-table-wrap">
|
||||||
|
<table class="woo-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Instance</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Last Sync</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<t t-foreach="state.instances" t-as="inst" t-key="inst.id">
|
||||||
|
<tr>
|
||||||
|
<td><strong><t t-esc="inst.name"/></strong></td>
|
||||||
|
<td>
|
||||||
|
<t t-if="inst.state === 'connected'">
|
||||||
|
<span class="woo-badge woo-badge-mapped">Connected</span>
|
||||||
|
</t>
|
||||||
|
<t t-elif="inst.state === 'error'">
|
||||||
|
<span class="woo-badge woo-badge-error">Error</span>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<span class="woo-badge woo-badge-unmapped">Draft</span>
|
||||||
|
</t>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<t t-if="inst.last_sync">
|
||||||
|
<t t-esc="inst.last_sync"/>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<span class="text-muted">Never</span>
|
||||||
|
</t>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</t>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</templates>
|
||||||
Reference in New Issue
Block a user