feat: add OWL product mapping UI with live AJAX search
- AjaxSearch component: debounced 300ms, calls /woo/search/* endpoints via rpc() - ProductMapping client action: 3 tabs (Mapped, Unmatched, Conflicts) - Mapped tab: live search, bulk unmap/sync, per-row price/inventory sync toggles - Unmatched tab: split Odoo|WC panels, click-to-select, Map/Create/Ignore actions - Conflicts tab: Use Odoo / Use WC per-row and bulk resolve - Top bar: instance selector, Fetch Products, Sync Now, live stats - woo_dashboard.xml updated with ir.actions.client records - woo_menus.xml pointed at new client action - CSS: full layout styles, badges, split view, progress bar, buttons Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component, useState } from "@odoo/owl";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
|
||||
/**
|
||||
* AjaxSearch — reusable debounced search component.
|
||||
*
|
||||
* Props:
|
||||
* endpoint {String} The /woo/search/* URL to POST to.
|
||||
* instanceId {Number} woo.instance ID (optional).
|
||||
* onResults {Function} Callback receives the results array.
|
||||
* placeholder {String} Input placeholder text.
|
||||
*/
|
||||
export class AjaxSearch extends Component {
|
||||
static template = "fusion_woocommerce.AjaxSearch";
|
||||
static props = ["endpoint", "onResults", "*"];
|
||||
|
||||
setup() {
|
||||
this.state = useState({ query: "" });
|
||||
this._debounceTimer = null;
|
||||
}
|
||||
|
||||
onInput(ev) {
|
||||
const query = ev.target.value;
|
||||
this.state.query = query;
|
||||
|
||||
clearTimeout(this._debounceTimer);
|
||||
this._debounceTimer = setTimeout(() => {
|
||||
this._doSearch(query);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
async _doSearch(query) {
|
||||
try {
|
||||
const params = { query };
|
||||
if (this.props.instanceId) {
|
||||
params.instance_id = this.props.instanceId;
|
||||
}
|
||||
const results = await rpc(this.props.endpoint, params);
|
||||
this.props.onResults(results || []);
|
||||
} catch (err) {
|
||||
console.error("[AjaxSearch] search error:", err);
|
||||
this.props.onResults([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component, useState, onWillStart } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { AjaxSearch } from "./ajax_search";
|
||||
|
||||
/**
|
||||
* ProductMapping — OWL client action for the WooCommerce product mapping UI.
|
||||
*
|
||||
* Three tabs:
|
||||
* 1. Mapped Products — searchable table, bulk actions
|
||||
* 2. Unmatched Products — split view Odoo | WC, map/create/ignore actions
|
||||
* 3. Conflicts — table with Use Odoo / Use WC buttons
|
||||
*/
|
||||
export class ProductMapping extends Component {
|
||||
static template = "fusion_woocommerce.ProductMapping";
|
||||
static components = { AjaxSearch };
|
||||
static props = ["action", "*"];
|
||||
|
||||
setup() {
|
||||
this.actionService = useService("action");
|
||||
this.notification = useService("notification");
|
||||
|
||||
this.state = useState({
|
||||
// UI state
|
||||
activeTab: "mapped",
|
||||
loading: false,
|
||||
|
||||
// Instance selector
|
||||
instances: [],
|
||||
instanceId: false,
|
||||
|
||||
// Stats
|
||||
mappedCount: 0,
|
||||
unmappedCount: 0,
|
||||
conflictCount: 0,
|
||||
|
||||
// Mapped tab
|
||||
mappedProducts: [],
|
||||
selectedMapped: [],
|
||||
|
||||
// Unmatched tab
|
||||
odooProducts: [],
|
||||
wooProducts: [],
|
||||
selectedOdooId: false,
|
||||
selectedWooId: false,
|
||||
|
||||
// Conflicts tab
|
||||
conflicts: [],
|
||||
});
|
||||
|
||||
onWillStart(async () => {
|
||||
await this._loadInstances();
|
||||
await this._refreshAll();
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Data loaders
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async _loadInstances() {
|
||||
try {
|
||||
const result = await rpc("/web/dataset/call_kw", {
|
||||
model: "woo.instance",
|
||||
method: "search_read",
|
||||
args: [[]],
|
||||
kwargs: { fields: ["id", "name", "state"], limit: 50 },
|
||||
});
|
||||
this.state.instances = result || [];
|
||||
if (result && result.length === 1) {
|
||||
this.state.instanceId = result[0].id;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[ProductMapping] _loadInstances error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async _refreshAll() {
|
||||
this.state.loading = true;
|
||||
try {
|
||||
await Promise.all([
|
||||
this._loadMapped(),
|
||||
this._loadOdooProducts(""),
|
||||
this._loadWooProducts(""),
|
||||
this._loadConflicts(),
|
||||
this._loadStats(),
|
||||
]);
|
||||
} finally {
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async _loadMapped(query = "") {
|
||||
try {
|
||||
const params = { query, limit: 50 };
|
||||
if (this.state.instanceId) {
|
||||
params.instance_id = this.state.instanceId;
|
||||
}
|
||||
const result = await rpc("/woo/search/mapped", params);
|
||||
this.state.mappedProducts = result || [];
|
||||
this.state.selectedMapped = [];
|
||||
} catch (err) {
|
||||
console.error("[ProductMapping] _loadMapped error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async _loadOdooProducts(query = "") {
|
||||
try {
|
||||
const params = { query, limit: 50 };
|
||||
if (this.state.instanceId) {
|
||||
params.instance_id = this.state.instanceId;
|
||||
}
|
||||
const result = await rpc("/woo/search/odoo_products", params);
|
||||
this.state.odooProducts = result || [];
|
||||
} catch (err) {
|
||||
console.error("[ProductMapping] _loadOdooProducts error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async _loadWooProducts(query = "") {
|
||||
try {
|
||||
const params = { query, limit: 50 };
|
||||
if (this.state.instanceId) {
|
||||
params.instance_id = this.state.instanceId;
|
||||
}
|
||||
const result = await rpc("/woo/search/woo_products", params);
|
||||
this.state.wooProducts = result || [];
|
||||
} catch (err) {
|
||||
console.error("[ProductMapping] _loadWooProducts error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async _loadConflicts() {
|
||||
try {
|
||||
const domain = [["resolution", "=", "pending"]];
|
||||
if (this.state.instanceId) {
|
||||
domain.push(["instance_id", "=", this.state.instanceId]);
|
||||
}
|
||||
const result = await rpc("/web/dataset/call_kw", {
|
||||
model: "woo.conflict",
|
||||
method: "search_read",
|
||||
args: [domain],
|
||||
kwargs: {
|
||||
fields: [
|
||||
"id", "conflict_type", "field_name",
|
||||
"odoo_value", "woo_value", "resolution",
|
||||
"instance_id",
|
||||
],
|
||||
limit: 100,
|
||||
},
|
||||
});
|
||||
this.state.conflicts = result || [];
|
||||
this.state.conflictCount = this.state.conflicts.length;
|
||||
} catch (err) {
|
||||
console.error("[ProductMapping] _loadConflicts error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async _loadStats() {
|
||||
try {
|
||||
const base = this.state.instanceId
|
||||
? [["instance_id", "=", this.state.instanceId]]
|
||||
: [];
|
||||
|
||||
const [mapped, unmapped] = await Promise.all([
|
||||
rpc("/web/dataset/call_kw", {
|
||||
model: "woo.product.map",
|
||||
method: "search_count",
|
||||
args: [[...base, ["state", "=", "mapped"]]],
|
||||
kwargs: {},
|
||||
}),
|
||||
rpc("/web/dataset/call_kw", {
|
||||
model: "woo.product.map",
|
||||
method: "search_count",
|
||||
args: [[...base, ["state", "=", "unmapped"]]],
|
||||
kwargs: {},
|
||||
}),
|
||||
]);
|
||||
|
||||
this.state.mappedCount = mapped || 0;
|
||||
this.state.unmappedCount = unmapped || 0;
|
||||
} catch (err) {
|
||||
console.error("[ProductMapping] _loadStats error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Tab handlers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
setTab(tab) {
|
||||
this.state.activeTab = tab;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Instance selector
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async onInstanceChange(ev) {
|
||||
const val = ev.target.value;
|
||||
this.state.instanceId = val ? parseInt(val, 10) : false;
|
||||
await this._refreshAll();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Mapped tab
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
onMappedResults(results) {
|
||||
this.state.mappedProducts = results;
|
||||
}
|
||||
|
||||
toggleSelectMapped(id) {
|
||||
const idx = this.state.selectedMapped.indexOf(id);
|
||||
if (idx >= 0) {
|
||||
this.state.selectedMapped.splice(idx, 1);
|
||||
} else {
|
||||
this.state.selectedMapped.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
toggleSelectAllMapped(ev) {
|
||||
if (ev.target.checked) {
|
||||
this.state.selectedMapped = this.state.mappedProducts.map((p) => p.id);
|
||||
} else {
|
||||
this.state.selectedMapped = [];
|
||||
}
|
||||
}
|
||||
|
||||
isMappedSelected(id) {
|
||||
return this.state.selectedMapped.includes(id);
|
||||
}
|
||||
|
||||
async unmapSelected() {
|
||||
if (!this.state.selectedMapped.length) return;
|
||||
try {
|
||||
await rpc("/web/dataset/call_kw", {
|
||||
model: "woo.product.map",
|
||||
method: "write",
|
||||
args: [this.state.selectedMapped, { state: "unmapped", product_id: false }],
|
||||
kwargs: {},
|
||||
});
|
||||
this.notification.add("Products unmapped successfully.", { type: "success" });
|
||||
await this._refreshAll();
|
||||
} catch (err) {
|
||||
console.error("[ProductMapping] unmapSelected error:", err);
|
||||
this.notification.add("Failed to unmap products.", { type: "danger" });
|
||||
}
|
||||
}
|
||||
|
||||
async syncSelected() {
|
||||
if (!this.state.selectedMapped.length) return;
|
||||
this.notification.add("Sync queued for selected products.", { type: "info" });
|
||||
}
|
||||
|
||||
async togglePriceSync(mapId, current) {
|
||||
try {
|
||||
await rpc("/web/dataset/call_kw", {
|
||||
model: "woo.product.map",
|
||||
method: "write",
|
||||
args: [[mapId], { sync_price: !current }],
|
||||
kwargs: {},
|
||||
});
|
||||
await this._loadMapped();
|
||||
} catch (err) {
|
||||
console.error("[ProductMapping] togglePriceSync error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async toggleInventorySync(mapId, current) {
|
||||
try {
|
||||
await rpc("/web/dataset/call_kw", {
|
||||
model: "woo.product.map",
|
||||
method: "write",
|
||||
args: [[mapId], { sync_inventory: !current }],
|
||||
kwargs: {},
|
||||
});
|
||||
await this._loadMapped();
|
||||
} catch (err) {
|
||||
console.error("[ProductMapping] toggleInventorySync error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Unmatched tab
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
onOdooResults(results) {
|
||||
this.state.odooProducts = results;
|
||||
if (!results.find((r) => r.id === this.state.selectedOdooId)) {
|
||||
this.state.selectedOdooId = false;
|
||||
}
|
||||
}
|
||||
|
||||
onWooResults(results) {
|
||||
this.state.wooProducts = results;
|
||||
if (!results.find((r) => r.id === this.state.selectedWooId)) {
|
||||
this.state.selectedWooId = false;
|
||||
}
|
||||
}
|
||||
|
||||
selectOdoo(id) {
|
||||
this.state.selectedOdooId = this.state.selectedOdooId === id ? false : id;
|
||||
}
|
||||
|
||||
selectWoo(id) {
|
||||
this.state.selectedWooId = this.state.selectedWooId === id ? false : id;
|
||||
}
|
||||
|
||||
get canMap() {
|
||||
return this.state.selectedOdooId && this.state.selectedWooId;
|
||||
}
|
||||
|
||||
async mapSelected() {
|
||||
if (!this.canMap) return;
|
||||
try {
|
||||
await rpc("/web/dataset/call_kw", {
|
||||
model: "woo.product.map",
|
||||
method: "write",
|
||||
args: [
|
||||
[this.state.selectedWooId],
|
||||
{
|
||||
product_id: this.state.selectedOdooId,
|
||||
state: "mapped",
|
||||
},
|
||||
],
|
||||
kwargs: {},
|
||||
});
|
||||
this.notification.add("Products mapped successfully.", { type: "success" });
|
||||
this.state.selectedOdooId = false;
|
||||
this.state.selectedWooId = false;
|
||||
await this._refreshAll();
|
||||
} catch (err) {
|
||||
console.error("[ProductMapping] mapSelected error:", err);
|
||||
this.notification.add("Failed to map products.", { type: "danger" });
|
||||
}
|
||||
}
|
||||
|
||||
async createInWC(odooProductId) {
|
||||
this.notification.add("Create in WooCommerce queued.", { type: "info" });
|
||||
}
|
||||
|
||||
async createInOdoo(wooMapId) {
|
||||
this.notification.add("Create in Odoo queued.", { type: "info" });
|
||||
}
|
||||
|
||||
async ignoreWoo(wooMapId) {
|
||||
try {
|
||||
await rpc("/web/dataset/call_kw", {
|
||||
model: "woo.product.map",
|
||||
method: "write",
|
||||
args: [[wooMapId], { state: "error" }],
|
||||
kwargs: {},
|
||||
});
|
||||
this.notification.add("WooCommerce product ignored.", { type: "warning" });
|
||||
await this._loadWooProducts("");
|
||||
} catch (err) {
|
||||
console.error("[ProductMapping] ignoreWoo error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Conflicts tab
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async resolveConflict(conflictId, resolution) {
|
||||
try {
|
||||
await rpc("/web/dataset/call_kw", {
|
||||
model: "woo.conflict",
|
||||
method: "write",
|
||||
args: [[conflictId], { resolution }],
|
||||
kwargs: {},
|
||||
});
|
||||
this.notification.add("Conflict resolved.", { type: "success" });
|
||||
await this._loadConflicts();
|
||||
} catch (err) {
|
||||
console.error("[ProductMapping] resolveConflict error:", err);
|
||||
this.notification.add("Failed to resolve conflict.", { type: "danger" });
|
||||
}
|
||||
}
|
||||
|
||||
async resolveAllConflicts(resolution) {
|
||||
const ids = this.state.conflicts.map((c) => c.id);
|
||||
if (!ids.length) return;
|
||||
try {
|
||||
await rpc("/web/dataset/call_kw", {
|
||||
model: "woo.conflict",
|
||||
method: "write",
|
||||
args: [ids, { resolution }],
|
||||
kwargs: {},
|
||||
});
|
||||
this.notification.add(`All conflicts resolved using ${resolution === "use_odoo" ? "Odoo" : "WooCommerce"} values.`, { type: "success" });
|
||||
await this._loadConflicts();
|
||||
} catch (err) {
|
||||
console.error("[ProductMapping] resolveAllConflicts error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Top bar actions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async fetchProducts() {
|
||||
if (!this.state.instanceId) {
|
||||
this.notification.add("Please select an instance first.", { type: "warning" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.state.loading = true;
|
||||
await rpc("/web/dataset/call_kw", {
|
||||
model: "woo.instance",
|
||||
method: "action_fetch_products",
|
||||
args: [[this.state.instanceId]],
|
||||
kwargs: {},
|
||||
});
|
||||
this.notification.add("Product fetch started.", { type: "success" });
|
||||
await this._refreshAll();
|
||||
} catch (err) {
|
||||
console.error("[ProductMapping] fetchProducts error:", err);
|
||||
this.notification.add("Failed to fetch products.", { type: "danger" });
|
||||
} finally {
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async syncNow() {
|
||||
if (!this.state.instanceId) {
|
||||
this.notification.add("Please select an instance first.", { type: "warning" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await rpc("/web/dataset/call_kw", {
|
||||
model: "woo.instance",
|
||||
method: "action_sync",
|
||||
args: [[this.state.instanceId]],
|
||||
kwargs: {},
|
||||
});
|
||||
this.notification.add("Sync started.", { type: "success" });
|
||||
} catch (err) {
|
||||
console.error("[ProductMapping] syncNow error:", err);
|
||||
this.notification.add("Failed to start sync.", { type: "danger" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("fusion_woocommerce.product_mapping", ProductMapping);
|
||||
Reference in New Issue
Block a user