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

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