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:
gsinghpal
2026-03-31 20:53:43 -04:00
parent 1f86a7c497
commit de14a28112
7 changed files with 1225 additions and 10 deletions

View File

@@ -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'],

View File

@@ -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;
}

View File

@@ -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([]);
}
}
}

View File

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

View File

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

View File

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

View File

@@ -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"