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:
gsinghpal
2026-03-31 20:53:50 -04:00
parent de14a28112
commit 2dd1a2be6c
2 changed files with 363 additions and 0 deletions

View 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);

View 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>