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:
@@ -35,6 +35,11 @@
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'fusion_woocommerce/static/src/css/woo_styles.css',
|
||||
'fusion_woocommerce/static/src/js/ajax_search.js',
|
||||
'fusion_woocommerce/static/src/js/product_mapping.js',
|
||||
'fusion_woocommerce/static/src/js/dashboard.js',
|
||||
'fusion_woocommerce/static/src/xml/product_mapping.xml',
|
||||
'fusion_woocommerce/static/src/xml/dashboard.xml',
|
||||
],
|
||||
},
|
||||
'images': ['static/description/icon.png'],
|
||||
|
||||
@@ -1 +1,372 @@
|
||||
/* Fusion WooCommerce — custom styles */
|
||||
/* ============================================================
|
||||
Fusion WooCommerce — Custom Styles
|
||||
============================================================ */
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
Status badges
|
||||
---------------------------------------------------------- */
|
||||
.woo-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.woo-badge-mapped { background: #d1fae5; color: #065f46; }
|
||||
.woo-badge-unmapped { background: #f3f4f6; color: #4b5563; }
|
||||
.woo-badge-conflict { background: #fef3c7; color: #92400e; }
|
||||
.woo-badge-error { background: #fee2e2; color: #991b1b; }
|
||||
.woo-badge-success { background: #d1fae5; color: #065f46; }
|
||||
.woo-badge-failed { background: #fee2e2; color: #991b1b; }
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
Tab navigation
|
||||
---------------------------------------------------------- */
|
||||
.woo-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.woo-tab {
|
||||
padding: 8px 20px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
background: none;
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.woo-tab:hover { color: #374151; }
|
||||
.woo-tab.active {
|
||||
color: #7c3aed;
|
||||
border-bottom-color: #7c3aed;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
Top bar / stats
|
||||
---------------------------------------------------------- */
|
||||
.woo-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: #f9fafb;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.woo-topbar select,
|
||||
.woo-topbar input {
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.woo-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 4px 14px;
|
||||
border-left: 1px solid #e5e7eb;
|
||||
}
|
||||
.woo-stat-value {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
.woo-stat-label {
|
||||
font-size: 0.7rem;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
Search bar
|
||||
---------------------------------------------------------- */
|
||||
.woo-search-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.woo-search-wrap .woo-search-icon {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.woo-search-input {
|
||||
padding: 6px 10px 6px 32px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
width: 240px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.woo-search-input:focus {
|
||||
outline: none;
|
||||
border-color: #7c3aed;
|
||||
box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.15);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
Tables
|
||||
---------------------------------------------------------- */
|
||||
.woo-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.woo-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.woo-table th {
|
||||
background: #f3f4f6;
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.woo-table td {
|
||||
padding: 9px 12px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
color: #374151;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.woo-table tr:hover td { background: #f9fafb; }
|
||||
.woo-table tr.selected td { background: #ede9fe; }
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
Split view (unmatched tab)
|
||||
---------------------------------------------------------- */
|
||||
.woo-split {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 40px 1fr;
|
||||
gap: 0;
|
||||
align-items: start;
|
||||
}
|
||||
.woo-split-panel {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.woo-split-panel-header {
|
||||
background: #f3f4f6;
|
||||
padding: 10px 14px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.woo-split-divider {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-top: 60px;
|
||||
gap: 8px;
|
||||
color: #9ca3af;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.woo-split-list {
|
||||
max-height: 480px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.woo-split-item {
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.woo-split-item:hover { background: #f9fafb; }
|
||||
.woo-split-item.selected { background: #ede9fe; }
|
||||
.woo-split-item-name { font-weight: 500; color: #111827; }
|
||||
.woo-split-item-sub { font-size: 0.75rem; color: #6b7280; margin-top: 1px; }
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
Map actions bar
|
||||
---------------------------------------------------------- */
|
||||
.woo-map-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 0 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
Buttons
|
||||
---------------------------------------------------------- */
|
||||
.woo-btn {
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.woo-btn-primary { background: #7c3aed; color: #fff; border-color: #7c3aed; }
|
||||
.woo-btn-primary:hover { background: #6d28d9; }
|
||||
.woo-btn-success { background: #059669; color: #fff; border-color: #059669; }
|
||||
.woo-btn-success:hover { background: #047857; }
|
||||
.woo-btn-warning { background: #d97706; color: #fff; border-color: #d97706; }
|
||||
.woo-btn-warning:hover { background: #b45309; }
|
||||
.woo-btn-danger { background: #dc2626; color: #fff; border-color: #dc2626; }
|
||||
.woo-btn-danger:hover { background: #b91c1c; }
|
||||
.woo-btn-secondary { background: #fff; color: #374151; border-color: #d1d5db; }
|
||||
.woo-btn-secondary:hover { background: #f3f4f6; }
|
||||
.woo-btn-sm { padding: 3px 10px; font-size: 0.8rem; }
|
||||
.woo-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
Dashboard cards
|
||||
---------------------------------------------------------- */
|
||||
.woo-dashboard {
|
||||
padding: 20px;
|
||||
}
|
||||
.woo-dashboard-title {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.woo-dashboard-subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.woo-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.woo-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
.woo-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
|
||||
.woo-card-clickable { cursor: pointer; }
|
||||
.woo-card-icon {
|
||||
font-size: 1.6rem;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.woo-card-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
line-height: 1;
|
||||
}
|
||||
.woo-card-label {
|
||||
font-size: 0.8rem;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.woo-card-sub {
|
||||
font-size: 0.78rem;
|
||||
color: #9ca3af;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.woo-card-pending { border-left: 4px solid #f59e0b; }
|
||||
.woo-card-errors { border-left: 4px solid #ef4444; }
|
||||
.woo-card-mapped { border-left: 4px solid #10b981; }
|
||||
.woo-card-sync { border-left: 4px solid #6366f1; }
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
Progress bar
|
||||
---------------------------------------------------------- */
|
||||
.woo-progress-wrap {
|
||||
background: #e5e7eb;
|
||||
border-radius: 6px;
|
||||
height: 8px;
|
||||
overflow: hidden;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.woo-progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #10b981, #059669);
|
||||
border-radius: 6px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
Loading spinner
|
||||
---------------------------------------------------------- */
|
||||
.woo-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
color: #6b7280;
|
||||
gap: 10px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.woo-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-top-color: #7c3aed;
|
||||
border-radius: 50%;
|
||||
animation: woo-spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes woo-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
Empty states
|
||||
---------------------------------------------------------- */
|
||||
.woo-empty {
|
||||
text-align: center;
|
||||
padding: 48px 20px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.woo-empty-icon { font-size: 2.5rem; margin-bottom: 10px; }
|
||||
.woo-empty-text { font-size: 0.9rem; }
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
Quick actions
|
||||
---------------------------------------------------------- */
|
||||
.woo-quick-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
Section header
|
||||
---------------------------------------------------------- */
|
||||
.woo-section-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
Checkbox column
|
||||
---------------------------------------------------------- */
|
||||
.woo-table th.woo-check-col,
|
||||
.woo-table td.woo-check-col {
|
||||
width: 36px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1,342 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<!-- ===================================================================
|
||||
AjaxSearch
|
||||
=================================================================== -->
|
||||
<t t-name="fusion_woocommerce.AjaxSearch">
|
||||
<div class="woo-search-wrap">
|
||||
<span class="woo-search-icon fa fa-search"/>
|
||||
<input
|
||||
type="text"
|
||||
class="woo-search-input"
|
||||
t-att-placeholder="props.placeholder or 'Search…'"
|
||||
t-att-value="state.query"
|
||||
t-on-input="onInput"
|
||||
/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- ===================================================================
|
||||
ProductMapping — main client action
|
||||
=================================================================== -->
|
||||
<t t-name="fusion_woocommerce.ProductMapping">
|
||||
<div class="o_action o_client_action">
|
||||
|
||||
<!-- Top bar -->
|
||||
<div class="woo-topbar">
|
||||
<!-- Instance selector -->
|
||||
<select t-on-change="onInstanceChange">
|
||||
<option value="">All Instances</option>
|
||||
<t t-foreach="state.instances" t-as="inst" t-key="inst.id">
|
||||
<option t-att-value="inst.id" t-att-selected="state.instanceId === inst.id">
|
||||
<t t-esc="inst.name"/>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
|
||||
<!-- Fetch / Sync buttons -->
|
||||
<button class="woo-btn woo-btn-secondary" t-on-click="fetchProducts"
|
||||
t-att-disabled="state.loading">
|
||||
<i class="fa fa-download me-1"/> Fetch Products
|
||||
</button>
|
||||
<button class="woo-btn woo-btn-primary" t-on-click="syncNow"
|
||||
t-att-disabled="state.loading">
|
||||
<i class="fa fa-refresh me-1"/> Sync Now
|
||||
</button>
|
||||
|
||||
<!-- Spacer -->
|
||||
<div class="flex-grow-1"/>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="woo-stat">
|
||||
<span class="woo-stat-value" t-esc="state.mappedCount"/>
|
||||
<span class="woo-stat-label">Mapped</span>
|
||||
</div>
|
||||
<div class="woo-stat">
|
||||
<span class="woo-stat-value" t-esc="state.unmappedCount"/>
|
||||
<span class="woo-stat-label">Unmapped</span>
|
||||
</div>
|
||||
<div class="woo-stat">
|
||||
<span class="woo-stat-value" t-esc="state.conflictCount"/>
|
||||
<span class="woo-stat-label">Conflicts</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading spinner -->
|
||||
<t t-if="state.loading">
|
||||
<div class="woo-loading">
|
||||
<div class="woo-spinner"/>
|
||||
Loading…
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-else="">
|
||||
<div class="p-3">
|
||||
<!-- Tab navigation -->
|
||||
<div class="woo-tabs">
|
||||
<button class="woo-tab" t-att-class="state.activeTab === 'mapped' ? 'active' : ''"
|
||||
t-on-click="() => this.setTab('mapped')">
|
||||
Mapped Products
|
||||
<span class="ms-1 badge bg-secondary" t-esc="state.mappedProducts.length"/>
|
||||
</button>
|
||||
<button class="woo-tab" t-att-class="state.activeTab === 'unmatched' ? 'active' : ''"
|
||||
t-on-click="() => this.setTab('unmatched')">
|
||||
Unmatched Products
|
||||
<span class="ms-1 badge bg-warning text-dark" t-esc="state.wooProducts.length"/>
|
||||
</button>
|
||||
<button class="woo-tab" t-att-class="state.activeTab === 'conflicts' ? 'active' : ''"
|
||||
t-on-click="() => this.setTab('conflicts')">
|
||||
Conflicts
|
||||
<span class="ms-1 badge bg-danger" t-esc="state.conflictCount"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- =====================================================
|
||||
TAB: Mapped Products
|
||||
===================================================== -->
|
||||
<t t-if="state.activeTab === 'mapped'">
|
||||
<div class="woo-map-actions">
|
||||
<AjaxSearch
|
||||
endpoint="'/woo/search/mapped'"
|
||||
t-props="{ instanceId: state.instanceId, onResults: onMappedResults.bind(this), placeholder: 'Search mapped products…' }"
|
||||
/>
|
||||
<button class="woo-btn woo-btn-danger woo-btn-sm"
|
||||
t-on-click="unmapSelected"
|
||||
t-att-disabled="!state.selectedMapped.length">
|
||||
<i class="fa fa-unlink me-1"/> Unmap Selected
|
||||
</button>
|
||||
<button class="woo-btn woo-btn-success woo-btn-sm"
|
||||
t-on-click="syncSelected"
|
||||
t-att-disabled="!state.selectedMapped.length">
|
||||
<i class="fa fa-refresh me-1"/> Sync Selected
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<t t-if="!state.mappedProducts.length">
|
||||
<div class="woo-empty">
|
||||
<div class="woo-empty-icon">🔗</div>
|
||||
<div class="woo-empty-text">No mapped products found.</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="woo-table-wrap">
|
||||
<table class="woo-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="woo-check-col">
|
||||
<input type="checkbox" t-on-change="toggleSelectAllMapped"/>
|
||||
</th>
|
||||
<th>WooCommerce Product</th>
|
||||
<th>SKU</th>
|
||||
<th>Odoo Product</th>
|
||||
<th>Instance</th>
|
||||
<th>Price Sync</th>
|
||||
<th>Inventory Sync</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="state.mappedProducts" t-as="p" t-key="p.id">
|
||||
<tr t-att-class="isMappedSelected(p.id) ? 'selected' : ''">
|
||||
<td class="woo-check-col">
|
||||
<input type="checkbox"
|
||||
t-att-checked="isMappedSelected(p.id)"
|
||||
t-on-change="() => this.toggleSelectMapped(p.id)"/>
|
||||
</td>
|
||||
<td><t t-esc="p.woo_product_name"/></td>
|
||||
<td><code><t t-esc="p.woo_sku"/></code></td>
|
||||
<td><t t-esc="p.odoo_product_name"/></td>
|
||||
<td><t t-esc="p.instance_name"/></td>
|
||||
<td>
|
||||
<input type="checkbox"
|
||||
t-att-checked="p.sync_price"
|
||||
t-on-change="() => this.togglePriceSync(p.id, p.sync_price)"/>
|
||||
</td>
|
||||
<td>
|
||||
<input type="checkbox"
|
||||
t-att-checked="p.sync_inventory"
|
||||
t-on-change="() => this.toggleInventorySync(p.id, p.sync_inventory)"/>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<!-- =====================================================
|
||||
TAB: Unmatched Products
|
||||
===================================================== -->
|
||||
<t t-if="state.activeTab === 'unmatched'">
|
||||
<!-- Map action bar -->
|
||||
<div class="woo-map-actions">
|
||||
<button class="woo-btn woo-btn-primary"
|
||||
t-on-click="mapSelected"
|
||||
t-att-disabled="!canMap">
|
||||
<i class="fa fa-link me-1"/> Map Selected
|
||||
</button>
|
||||
<t t-if="!canMap">
|
||||
<small class="text-muted align-self-center">
|
||||
Select one Odoo product and one WooCommerce product to map them.
|
||||
</small>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<div class="woo-split">
|
||||
<!-- Odoo products panel -->
|
||||
<div class="woo-split-panel">
|
||||
<div class="woo-split-panel-header">
|
||||
<span>Odoo Products</span>
|
||||
<AjaxSearch
|
||||
endpoint="'/woo/search/odoo_products'"
|
||||
t-props="{ instanceId: state.instanceId, onResults: onOdooResults.bind(this), placeholder: 'Search Odoo…' }"
|
||||
/>
|
||||
</div>
|
||||
<div class="woo-split-list">
|
||||
<t t-if="!state.odooProducts.length">
|
||||
<div class="woo-empty">
|
||||
<div class="woo-empty-text">No Odoo products found.</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-foreach="state.odooProducts" t-as="op" t-key="op.id">
|
||||
<div class="woo-split-item"
|
||||
t-att-class="state.selectedOdooId === op.id ? 'selected' : ''"
|
||||
t-on-click="() => this.selectOdoo(op.id)">
|
||||
<div class="woo-split-item-name"><t t-esc="op.name"/></div>
|
||||
<div class="woo-split-item-sub">
|
||||
<t t-if="op.default_code">SKU: <t t-esc="op.default_code"/> · </t>
|
||||
$<t t-esc="op.list_price.toFixed(2)"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="woo-split-divider">
|
||||
<i class="fa fa-exchange text-muted"/>
|
||||
</div>
|
||||
|
||||
<!-- WooCommerce products panel -->
|
||||
<div class="woo-split-panel">
|
||||
<div class="woo-split-panel-header">
|
||||
<span>WooCommerce Products</span>
|
||||
<AjaxSearch
|
||||
endpoint="'/woo/search/woo_products'"
|
||||
t-props="{ instanceId: state.instanceId, onResults: onWooResults.bind(this), placeholder: 'Search WooCommerce…' }"
|
||||
/>
|
||||
</div>
|
||||
<div class="woo-split-list">
|
||||
<t t-if="!state.wooProducts.length">
|
||||
<div class="woo-empty">
|
||||
<div class="woo-empty-text">No unmatched WooCommerce products.</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-foreach="state.wooProducts" t-as="wp" t-key="wp.id">
|
||||
<div class="woo-split-item"
|
||||
t-att-class="state.selectedWooId === wp.id ? 'selected' : ''"
|
||||
t-on-click="() => this.selectWoo(wp.id)">
|
||||
<div class="woo-split-item-name"><t t-esc="wp.woo_product_name"/></div>
|
||||
<div class="woo-split-item-sub">
|
||||
<t t-if="wp.woo_sku">SKU: <t t-esc="wp.woo_sku"/> · </t>
|
||||
<t t-esc="wp.woo_product_type"/>
|
||||
</div>
|
||||
<!-- Per-item actions -->
|
||||
<div class="mt-1 d-flex gap-1">
|
||||
<button class="woo-btn woo-btn-secondary woo-btn-sm"
|
||||
t-on-click.stop="() => this.createInOdoo(wp.id)">
|
||||
Create in Odoo
|
||||
</button>
|
||||
<button class="woo-btn woo-btn-danger woo-btn-sm"
|
||||
t-on-click.stop="() => this.ignoreWoo(wp.id)">
|
||||
Ignore
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Odoo-only actions (create in WC) shown below list -->
|
||||
<t t-if="state.selectedOdooId">
|
||||
<div class="mt-2">
|
||||
<button class="woo-btn woo-btn-secondary woo-btn-sm"
|
||||
t-on-click="() => this.createInWC(state.selectedOdooId)">
|
||||
<i class="fa fa-cloud-upload me-1"/> Create Selected in WooCommerce
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<!-- =====================================================
|
||||
TAB: Conflicts
|
||||
===================================================== -->
|
||||
<t t-if="state.activeTab === 'conflicts'">
|
||||
<t t-if="!state.conflicts.length">
|
||||
<div class="woo-empty">
|
||||
<div class="woo-empty-icon">✅</div>
|
||||
<div class="woo-empty-text">No pending conflicts. Everything is in sync!</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="woo-map-actions">
|
||||
<button class="woo-btn woo-btn-secondary woo-btn-sm"
|
||||
t-on-click="() => this.resolveAllConflicts('use_odoo')">
|
||||
Use Odoo for All
|
||||
</button>
|
||||
<button class="woo-btn woo-btn-secondary woo-btn-sm"
|
||||
t-on-click="() => this.resolveAllConflicts('use_woo')">
|
||||
Use WooCommerce for All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="woo-table-wrap">
|
||||
<table class="woo-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Field</th>
|
||||
<th>Odoo Value</th>
|
||||
<th>WooCommerce Value</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="state.conflicts" t-as="c" t-key="c.id">
|
||||
<tr>
|
||||
<td>
|
||||
<span class="woo-badge woo-badge-conflict">
|
||||
<t t-esc="c.conflict_type"/>
|
||||
</span>
|
||||
</td>
|
||||
<td><t t-esc="c.field_name"/></td>
|
||||
<td><t t-esc="c.odoo_value"/></td>
|
||||
<td><t t-esc="c.woo_value"/></td>
|
||||
<td>
|
||||
<div class="d-flex gap-1">
|
||||
<button class="woo-btn woo-btn-secondary woo-btn-sm"
|
||||
t-on-click="() => this.resolveConflict(c.id, 'use_odoo')">
|
||||
Use Odoo
|
||||
</button>
|
||||
<button class="woo-btn woo-btn-secondary woo-btn-sm"
|
||||
t-on-click="() => this.resolveConflict(c.id, 'use_woo')">
|
||||
Use WooCommerce
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -1,15 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!--
|
||||
Dashboard placeholder.
|
||||
The actual OWL component will be added in Task 15.
|
||||
For now, the dashboard menu points to the instance list.
|
||||
-->
|
||||
<record id="action_woo_dashboard" model="ir.actions.act_window">
|
||||
<!-- ===== Dashboard Client Action (Task 15) ===== -->
|
||||
<record id="action_woo_dashboard" model="ir.actions.client">
|
||||
<field name="name">WooCommerce Dashboard</field>
|
||||
<field name="res_model">woo.instance</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="tag">fusion_woocommerce.woo_dashboard</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== Product Mapping Client Action (Task 14) ===== -->
|
||||
<record id="action_woo_product_map_ui" model="ir.actions.client">
|
||||
<field name="name">Product Mapping</field>
|
||||
<field name="tag">fusion_woocommerce.product_mapping</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<menuitem id="woo_menu_product_map"
|
||||
name="Product Mapping"
|
||||
parent="woo_menu_operations"
|
||||
action="action_woo_product_map"
|
||||
action="action_woo_product_map_ui"
|
||||
sequence="10"/>
|
||||
|
||||
<menuitem id="woo_menu_orders"
|
||||
|
||||
Reference in New Issue
Block a user