feat: category filter dropdown on unmatched Odoo products panel

Filter by Odoo product category or clear filter. Backend supports
both include and exclude category filtering. Loads all categories
on init for the dropdown.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-01 15:56:13 -04:00
parent 9f0badfb7e
commit 52be90c10d
4 changed files with 119 additions and 2 deletions

View File

@@ -21,7 +21,8 @@ class WooProductSearchController(http.Controller):
'/woo/search/odoo_products',
type='jsonrpc', auth='user', methods=['POST'],
)
def search_odoo_products(self, query='', instance_id=None, limit=20, offset=0, **kw):
def search_odoo_products(self, query='', instance_id=None, limit=20, offset=0,
category_id=None, exclude_category_ids=None, **kw):
"""
Search Odoo products by name or internal reference (SKU).
@@ -30,6 +31,8 @@ class WooProductSearchController(http.Controller):
instance_id (int): woo.instance ID (used for future per-instance filtering).
limit (int): Max results to return (default 20).
offset (int): Offset for pagination (default 0).
category_id (int): Filter by Odoo product category.
exclude_category_ids (list): Exclude these category IDs.
Returns:
dict with 'results' list and 'total' count
@@ -45,6 +48,19 @@ class WooProductSearchController(http.Controller):
('default_code', 'ilike', query),
]
if category_id:
domain.append(('categ_id', '=', int(category_id)))
if exclude_category_ids:
if isinstance(exclude_category_ids, str):
import json as _json
try:
exclude_category_ids = _json.loads(exclude_category_ids)
except (ValueError, TypeError):
exclude_category_ids = []
if exclude_category_ids:
domain.append(('categ_id', 'not in', [int(x) for x in exclude_category_ids]))
total = request.env['product.product'].search_count(domain)
products = request.env['product.product'].search(domain, limit=limit, offset=offset)
@@ -56,6 +72,7 @@ class WooProductSearchController(http.Controller):
'default_code': p.default_code or '',
'list_price': p.list_price,
'qty_available': p.qty_available,
'categ_name': p.categ_id.name if p.categ_id else '',
}
for p in products
],
@@ -110,6 +127,18 @@ class WooProductSearchController(http.Controller):
'total': total,
}
@http.route(
'/woo/search/odoo_categories',
type='jsonrpc', auth='user', methods=['POST'],
)
def get_odoo_categories(self, **kw):
"""Return all Odoo product categories for filtering."""
categories = request.env['product.category'].search([], order='complete_name')
return [
{'id': c.id, 'name': c.name, 'complete_name': c.complete_name}
for c in categories
]
@http.route(
'/woo/search/mapped',
type='jsonrpc', auth='user', methods=['POST'],

View File

@@ -644,6 +644,21 @@ html[style*="color-scheme: dark"] {
box-shadow: 0 0 0 2px var(--woo-accent-glow);
}
/* Category filter dropdown */
.woo-filter-select {
padding: 3px 8px;
border: 1px solid var(--woo-input-border);
border-radius: 4px;
font-size: 0.8rem;
background: var(--woo-input-bg);
color: var(--woo-text-primary);
max-width: 200px;
}
.woo-filter-select option {
background: var(--woo-bg-primary);
color: var(--woo-text-primary);
}
.woo-edit-input-text {
text-align: left;
width: 120px;

View File

@@ -60,12 +60,18 @@ export class ProductMapping extends Component {
// Pagination
pageSize: 50,
// Category filters
odooCategories: [],
odooFilterCategoryId: false,
odooExcludeCategoryIds: [],
// Conflicts tab
conflicts: [],
});
onWillStart(async () => {
await this._loadInstances();
await this._loadOdooCategories();
await this._refreshAll();
});
}
@@ -125,6 +131,15 @@ export class ProductMapping extends Component {
}
}
async _loadOdooCategories() {
try {
const result = await rpc("/woo/search/odoo_categories", {});
this.state.odooCategories = result || [];
} catch (err) {
console.error("[ProductMapping] _loadOdooCategories error:", err);
}
}
async _loadOdooProducts(query = "") {
try {
const params = {
@@ -135,6 +150,12 @@ export class ProductMapping extends Component {
if (this.state.instanceId) {
params.instance_id = this.state.instanceId;
}
if (this.state.odooFilterCategoryId) {
params.category_id = this.state.odooFilterCategoryId;
}
if (this.state.odooExcludeCategoryIds.length) {
params.exclude_category_ids = JSON.stringify(this.state.odooExcludeCategoryIds);
}
const result = await rpc("/woo/search/odoo_products", params);
this.state.odooProducts = (result && result.results) || [];
this.state.unmatchedOdooTotal = (result && result.total) || 0;
@@ -260,6 +281,42 @@ export class ProductMapping extends Component {
await this._refreshAll();
}
// -------------------------------------------------------------------------
// Category filter
// -------------------------------------------------------------------------
async onOdooCategoryFilter(ev) {
const val = ev.target.value;
this.state.odooFilterCategoryId = val ? parseInt(val, 10) : false;
this.state.unmatchedOdooPage = 1;
await this._loadOdooProducts("");
}
toggleExcludeCategory(catId) {
const idx = this.state.odooExcludeCategoryIds.indexOf(catId);
if (idx >= 0) {
this.state.odooExcludeCategoryIds.splice(idx, 1);
} else {
this.state.odooExcludeCategoryIds.push(catId);
}
}
async applyExcludeCategories() {
this.state.unmatchedOdooPage = 1;
await this._loadOdooProducts("");
}
isCategoryExcluded(catId) {
return this.state.odooExcludeCategoryIds.includes(catId);
}
async clearCategoryFilter() {
this.state.odooFilterCategoryId = false;
this.state.odooExcludeCategoryIds = [];
this.state.unmatchedOdooPage = 1;
await this._loadOdooProducts("");
}
// -------------------------------------------------------------------------
// Mapped tab
// -------------------------------------------------------------------------

View File

@@ -333,8 +333,24 @@
<div class="woo-split">
<!-- Odoo products panel -->
<div class="woo-split-panel">
<div class="woo-split-panel-header">
<div class="woo-split-panel-header" style="flex-wrap: wrap; gap: 6px;">
<span>Odoo Products</span>
<div class="d-flex gap-2 align-items-center">
<select class="woo-filter-select" t-on-change="onOdooCategoryFilter">
<option value="">All Categories</option>
<t t-foreach="state.odooCategories" t-as="cat" t-key="cat.id">
<option t-att-value="cat.id"
t-att-selected="state.odooFilterCategoryId === cat.id">
<t t-esc="cat.complete_name"/>
</option>
</t>
</select>
<t t-if="state.odooFilterCategoryId || state.odooExcludeCategoryIds.length">
<button class="woo-btn-icon" title="Clear filter" t-on-click="clearCategoryFilter">
<i class="fa fa-times"/>
</button>
</t>
</div>
<AjaxSearch
endpoint="'/woo/search/odoo_products'"
t-props="{ instanceId: state.instanceId, onResults: onOdooResults.bind(this), placeholder: 'Search Odoo…' }"