From 9da14e40daeeaa65a8c4ce42514931987f4d8556 Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Thu, 14 May 2026 21:23:02 +0000 Subject: [PATCH] feat(catalog): brand search, parts pagination, and parts search Backend: - Add 'search' param to /brand-parts endpoint (filters oem_part_number and name via ILIKE) - Keep count query accurate with search filter Frontend (brand-catalog.js): - Brand search input: filters 619 brands locally while typing - Parts pagination: Previous/Next buttons with page counter (50 per page) - Parts search within category: search input + Enter key triggers backend search - Visual polish: stock badges, empty-state messages, responsive layout - Loading states and breadcrumbs improved --- pos/blueprints/catalog_bp.py | 19 +++- pos/static/js/brand-catalog.js | 171 +++++++++++++++++++++++++++------ 2 files changed, 155 insertions(+), 35 deletions(-) diff --git a/pos/blueprints/catalog_bp.py b/pos/blueprints/catalog_bp.py index 3c43544..61c0b50 100644 --- a/pos/blueprints/catalog_bp.py +++ b/pos/blueprints/catalog_bp.py @@ -663,9 +663,10 @@ def brand_categories(): @catalog_bp.route('/brand-parts', methods=['GET']) @require_auth('catalog.view') def brand_parts(): - """Return parts for a given vehicle brand + category.""" + """Return parts for a given vehicle brand + category, optionally filtered by search term.""" brand = request.args.get('brand', '') category_id = request.args.get('category_id', type=int) + search = request.args.get('search', '').strip() limit = request.args.get('limit', 50, type=int) offset = request.args.get('offset', 0, type=int) @@ -675,13 +676,22 @@ def brand_parts(): def _query(master, tenant, branch_id): cur = master.cursor() try: - # Get parts from the brand catalog + # Build dynamic filters params = [brand] cat_filter = "" + search_filter = "" + if category_id: cat_filter = "AND pc.id_part_category = %s" params.append(category_id) + if search: + search_filter = "AND (p.oem_part_number ILIKE %s OR COALESCE(NULLIF(p.name_es, ''), p.name_part) ILIKE %s)" + like_term = f"%{search}%" + params.extend([like_term, like_term]) + + # Get parts from the brand catalog + query_params = list(params) cur.execute(f""" SELECT DISTINCT p.id_part, p.oem_part_number, COALESCE(NULLIF(p.name_es, ''), p.name_part) as name, @@ -693,9 +703,10 @@ def brand_parts(): JOIN part_categories pc ON pc.id_part_category = pg.category_id WHERE pvp.name_brand = %s {cat_filter} + {search_filter} ORDER BY p.id_part LIMIT %s OFFSET %s - """, params + [limit, offset]) + """, query_params + [limit, offset]) part_rows = cur.fetchall() part_ids = [r[0] for r in part_rows] @@ -709,6 +720,7 @@ def brand_parts(): JOIN part_categories pc ON pc.id_part_category = pg.category_id WHERE pvp.name_brand = %s {cat_filter} + {search_filter} """, params) total = cur.fetchone()[0] @@ -738,6 +750,7 @@ def brand_parts(): return jsonify({ 'brand': brand, 'category_id': category_id, + 'search': search, 'items': items, 'total': total, 'limit': limit, diff --git a/pos/static/js/brand-catalog.js b/pos/static/js/brand-catalog.js index 108826f..6c0d835 100644 --- a/pos/static/js/brand-catalog.js +++ b/pos/static/js/brand-catalog.js @@ -4,6 +4,10 @@ currentCategory: null, state: 'brands', _lastItems: [], + _allBrands: [], + _offset: 0, + _limit: 50, + _total: 0, el: function(id) { return document.getElementById(id); }, @@ -24,6 +28,9 @@ this.currentCategory = null; this.state = 'brands'; this._lastItems = []; + this._allBrands = []; + this._offset = 0; + this._total = 0; }, loading: function(on) { @@ -38,6 +45,7 @@ this.el('brandCatalogBreadcrumb').innerHTML = html; }, + // ---------- BRANDS ---------- loadBrands: function() { this.loading(true); this.state = 'brands'; @@ -46,18 +54,12 @@ .then(r => r.json()) .then(data => { this.loading(false); - if (!data.brands || !data.brands.length) { + this._allBrands = data.brands || []; + if (!this._allBrands.length) { this.setContent('

No se encontraron marcas.

'); return; } - let html = ''; - data.brands.forEach(b => { - html += '
' + - '
' + escapeHtml(b.name) + '
' + - '
' + (b.part_count || 0) + ' refacciones
' + - '
'; - }); - this.setContent(html); + this.renderBrandList(this._allBrands); }) .catch(err => { this.loading(false); @@ -65,11 +67,40 @@ }); }, + renderBrandList: function(brands) { + let html = '
' + + '' + + '
'; + brands.forEach(b => { + html += '
' + + '
' + escapeHtml(b.name) + '
' + + '
' + (b.part_count || 0) + ' refacciones
' + + '
'; + }); + this.setContent(html); + }, + + filterBrands: function(query) { + const q = query.toLowerCase().trim(); + if (!q) { + this.renderBrandList(this._allBrands); + return; + } + const filtered = this._allBrands.filter(function(b) { + return b.name.toLowerCase().indexOf(q) !== -1; + }); + this.renderBrandList(filtered); + }, + selectBrand: function(brandName) { this.currentBrand = brandName; this.loadCategories(brandName); }, + // ---------- CATEGORIES ---------- loadCategories: function(brandName) { this.loading(true); this.state = 'categories'; @@ -81,7 +112,12 @@ .then(data => { this.loading(false); if (!data.categories || !data.categories.length) { - this.setContent('

No se encontraron categorias para esta marca.

'); + this.setContent( + '
' + + '

No se encontraron categorias para ' + escapeHtml(brandName) + '.

' + + '' + + '
' + ); return; } let html = ''; @@ -101,10 +137,12 @@ selectCategory: function(catId, catName) { this.currentCategory = { id: catId, name: catName }; - this.loadParts(this.currentBrand, catId); + this._offset = 0; + this.loadParts(this.currentBrand, catId, ''); }, - loadParts: function(brandName, categoryId) { + // ---------- PARTS ---------- + loadParts: function(brandName, categoryId, searchTerm) { this.loading(true); this.state = 'parts'; this.setBreadcrumb( @@ -112,33 +150,23 @@ '' + escapeHtml(brandName) + ' › ' + '' + escapeHtml(this.currentCategory.name) + '' ); - fetch('/pos/api/catalog/brand-parts?brand=' + encodeURIComponent(brandName) + '&category_id=' + encodeURIComponent(categoryId)) + let url = '/pos/api/catalog/brand-parts?brand=' + encodeURIComponent(brandName) + '&category_id=' + encodeURIComponent(categoryId) + + '&limit=' + this._limit + '&offset=' + this._offset; + if (searchTerm) { + url += '&search=' + encodeURIComponent(searchTerm); + } + fetch(url) .then(r => r.json()) .then(data => { this.loading(false); this._lastItems = data.items || []; + this._total = data.total || 0; + this._offset = data.offset || 0; if (!data.items || !data.items.length) { - this.setContent('

No se encontraron refacciones.

'); + this.renderPartsList([], searchTerm); return; } - let html = '
'; - data.items.forEach(p => { - const price = p.local_price ? '$' + Number(p.local_price).toFixed(2) : 'Consultar precio'; - const img = '/pos/static/images/placeholder-part.png'; - html += '
' + - '
' + - '' + - '
' + - '
' + - '
' + escapeHtml(p.oem_part_number || 'N/A') + '
' + - '
' + escapeHtml(p.name || '') + '
' + - '
' + price + '
' + - '' + - '
' + - '
'; - }); - html += '
'; - this.setContent(html); + this.renderPartsList(data.items, searchTerm); }) .catch(err => { this.loading(false); @@ -146,6 +174,85 @@ }); }, + renderPartsList: function(items, searchTerm) { + let html = '
' + + '' + + '' + + '' + + '
'; + + if (!items.length) { + html += '
' + + '

No se encontraron refacciones.

' + + '' + + '
'; + this.setContent(html); + return; + } + + // Stats line + const startIdx = this._offset + 1; + const endIdx = this._offset + items.length; + html += '
' + + 'Mostrando ' + startIdx + '-' + endIdx + ' de ' + this._total + ' refacciones' + + '
'; + + html += '
'; + items.forEach(p => { + const price = p.local_price ? '$' + Number(p.local_price).toFixed(2) : 'Consultar precio'; + const img = '/pos/static/images/placeholder-part.png'; + const stockBadge = p.local_stock > 0 + ? '' + p.local_stock + ' en stock' + : 'Sin stock local'; + html += '
' + + '
' + + '' + + '
' + + '
' + + '
' + escapeHtml(p.oem_part_number || 'N/A') + stockBadge + '
' + + '
' + escapeHtml(p.name || '') + '
' + + '
' + price + '
' + + '' + + '
' + + '
'; + }); + html += '
'; + + // Pagination + const hasPrev = this._offset > 0; + const hasNext = (this._offset + this._limit) < this._total; + html += '
' + + '' + + 'Pagina ' + (Math.floor(this._offset / this._limit) + 1) + ' de ' + (Math.ceil(this._total / this._limit) || 1) + '' + + '' + + '
'; + + this.setContent(html); + }, + + searchParts: function(term) { + this._offset = 0; + this.loadParts(this.currentBrand, this.currentCategory.id, term); + }, + + clearPartsSearch: function() { + this._offset = 0; + this.loadParts(this.currentBrand, this.currentCategory.id, ''); + }, + + goToPage: function(newOffset) { + if (newOffset < 0) return; + this._offset = newOffset; + const searchInput = document.getElementById('partsSearchInput'); + const term = searchInput ? searchInput.value : ''; + this.loadParts(this.currentBrand, this.currentCategory.id, term); + }, + addToCart: function(partId, event) { if (event) event.stopPropagation(); const part = this._lastItems.find(function(p) { return p.id === partId; });