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