From ee9eea58c1990255cba9bb3f0ec9baa41851925a Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Thu, 14 May 2026 08:37:37 +0000 Subject: [PATCH] feat(catalog): wire up brand-first OEM catalog UI - Add brand-catalog.js overlay: Brands -> Categories -> Parts flow - Update catalog.html: 'Por Marca' button opens BrandCatalog overlay - Optimize /vehicle-brands to query brands table (fast) instead of 256M part_vehicle_preview - Keep /brand-categories and /brand-parts using exact match on part_vehicle_preview - Integrate addToCart with existing CatalogApp cart --- pos/blueprints/catalog_bp.py | 155 +++++++++++++++++++++++++++ pos/static/js/brand-catalog.js | 188 +++++++++++++++++++++++++++++++++ pos/templates/catalog.html | 17 ++- 3 files changed, 359 insertions(+), 1 deletion(-) create mode 100644 pos/static/js/brand-catalog.js diff --git a/pos/blueprints/catalog_bp.py b/pos/blueprints/catalog_bp.py index be2bfe5..3c43544 100644 --- a/pos/blueprints/catalog_bp.py +++ b/pos/blueprints/catalog_bp.py @@ -591,3 +591,158 @@ def _match_vin_to_catalog(master_conn, vin_info): return None finally: cur.close() + + +# ─── Brand Catalog (vehicle-brand-first navigation) ─── + +@catalog_bp.route('/vehicle-brands', methods=['GET']) +@require_auth('catalog.view') +def vehicle_brands(): + """Return vehicle brands for brand-first catalog browsing. + + Returns all brands from the brands table (fast) rather than scanning + the 256M-row part_vehicle_preview materialized view. + """ + def _query(master): + cur = master.cursor() + try: + cur.execute(""" + SELECT id_brand, name_brand + FROM brands + ORDER BY name_brand ASC + """) + rows = cur.fetchall() + return jsonify({ + 'brands': [ + {'id': r[0], 'name': r[1], 'part_count': 0} + for r in rows + ] + }) + finally: + cur.close() + return _master_only(_query) + + +@catalog_bp.route('/brand-categories', methods=['GET']) +@require_auth('catalog.view') +def brand_categories(): + """Return part categories available for a given vehicle brand.""" + brand = request.args.get('brand', '') + if not brand: + return jsonify({'error': 'brand parameter required'}), 400 + + def _query(master): + cur = master.cursor() + try: + cur.execute(""" + SELECT pc.id_part_category, + COALESCE(NULLIF(pc.name_es, ''), pc.name_part_category) as name, + pc.slug, + COUNT(DISTINCT p.id_part) as part_count + FROM part_vehicle_preview pvp + JOIN parts p ON p.id_part = pvp.part_id + JOIN part_groups pg ON pg.id_part_group = p.group_id + JOIN part_categories pc ON pc.id_part_category = pg.category_id + WHERE pvp.name_brand = %s + GROUP BY pc.id_part_category, pc.name_part_category, pc.name_es, pc.slug + ORDER BY part_count DESC + """, (brand,)) + rows = cur.fetchall() + return jsonify({ + 'brand': brand, + 'categories': [ + {'id': r[0], 'name': r[1], 'slug': r[2], 'part_count': r[3]} + for r in rows + ] + }) + finally: + cur.close() + return _master_only(_query) + + +@catalog_bp.route('/brand-parts', methods=['GET']) +@require_auth('catalog.view') +def brand_parts(): + """Return parts for a given vehicle brand + category.""" + brand = request.args.get('brand', '') + category_id = request.args.get('category_id', type=int) + limit = request.args.get('limit', 50, type=int) + offset = request.args.get('offset', 0, type=int) + + if not brand: + return jsonify({'error': 'brand parameter required'}), 400 + + def _query(master, tenant, branch_id): + cur = master.cursor() + try: + # Get parts from the brand catalog + params = [brand] + cat_filter = "" + if category_id: + cat_filter = "AND pc.id_part_category = %s" + params.append(category_id) + + cur.execute(f""" + SELECT DISTINCT p.id_part, p.oem_part_number, + COALESCE(NULLIF(p.name_es, ''), p.name_part) as name, + pg.id_part_group, pg.name_part_group, + pc.id_part_category, pc.name_part_category + FROM part_vehicle_preview pvp + JOIN parts p ON p.id_part = pvp.part_id + JOIN part_groups pg ON pg.id_part_group = p.group_id + JOIN part_categories pc ON pc.id_part_category = pg.category_id + WHERE pvp.name_brand = %s + {cat_filter} + ORDER BY p.id_part + LIMIT %s OFFSET %s + """, params + [limit, offset]) + + part_rows = cur.fetchall() + part_ids = [r[0] for r in part_rows] + + # Count total + cur.execute(f""" + SELECT COUNT(DISTINCT p.id_part) + FROM part_vehicle_preview pvp + JOIN parts p ON p.id_part = pvp.part_id + JOIN part_groups pg ON pg.id_part_group = p.group_id + JOIN part_categories pc ON pc.id_part_category = pg.category_id + WHERE pvp.name_brand = %s + {cat_filter} + """, params) + total = cur.fetchone()[0] + + # Enrich with local stock if available + local_stock = {} + if tenant and part_ids: + try: + from services.catalog_service import _get_local_stock_bulk + local_stock = _get_local_stock_bulk(tenant, part_ids) + except Exception: + pass + + items = [] + for r in part_rows: + part_id = r[0] + stock_info = local_stock.get(part_id, {}) + items.append({ + 'id': part_id, + 'oem_part_number': r[1], + 'name': r[2], + 'group': {'id': r[3], 'name': r[4]}, + 'category': {'id': r[5], 'name': r[6]}, + 'local_stock': stock_info.get('stock', 0), + 'local_price': stock_info.get('price', None), + }) + + return jsonify({ + 'brand': brand, + 'category_id': category_id, + 'items': items, + 'total': total, + 'limit': limit, + 'offset': offset, + }) + finally: + cur.close() + return _with_conns(_query) diff --git a/pos/static/js/brand-catalog.js b/pos/static/js/brand-catalog.js new file mode 100644 index 0000000..108826f --- /dev/null +++ b/pos/static/js/brand-catalog.js @@ -0,0 +1,188 @@ +(function() { + const BrandCatalog = { + currentBrand: null, + currentCategory: null, + state: 'brands', + _lastItems: [], + + el: function(id) { return document.getElementById(id); }, + + show: function() { + this.el('brandCatalogOverlay').style.display = 'block'; + document.body.style.overflow = 'hidden'; + this.loadBrands(); + }, + + hide: function() { + this.el('brandCatalogOverlay').style.display = 'none'; + document.body.style.overflow = ''; + this.reset(); + }, + + reset: function() { + this.currentBrand = null; + this.currentCategory = null; + this.state = 'brands'; + this._lastItems = []; + }, + + loading: function(on) { + this.el('brandCatalogLoading').style.display = on ? 'block' : 'none'; + }, + + setContent: function(html) { + this.el('brandCatalogContent').innerHTML = html; + }, + + setBreadcrumb: function(html) { + this.el('brandCatalogBreadcrumb').innerHTML = html; + }, + + loadBrands: function() { + this.loading(true); + this.state = 'brands'; + this.setBreadcrumb('Marcas de vehiculo'); + fetch('/pos/api/catalog/vehicle-brands') + .then(r => r.json()) + .then(data => { + this.loading(false); + if (!data.brands || !data.brands.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); + }) + .catch(err => { + this.loading(false); + this.setContent('

Error al cargar marcas: ' + escapeHtml(err.message) + '

'); + }); + }, + + selectBrand: function(brandName) { + this.currentBrand = brandName; + this.loadCategories(brandName); + }, + + loadCategories: function(brandName) { + this.loading(true); + this.state = 'categories'; + this.setBreadcrumb( + 'Marcas' + escapeHtml(brandName) + '' + ); + fetch('/pos/api/catalog/brand-categories?brand=' + encodeURIComponent(brandName)) + .then(r => r.json()) + .then(data => { + this.loading(false); + if (!data.categories || !data.categories.length) { + this.setContent('

No se encontraron categorias para esta marca.

'); + return; + } + let html = ''; + data.categories.forEach(c => { + html += '
' + + '
' + escapeHtml(c.name) + '
' + + '
' + c.part_count + ' refacciones
' + + '
'; + }); + this.setContent(html); + }) + .catch(err => { + this.loading(false); + this.setContent('

Error al cargar categorias: ' + escapeHtml(err.message) + '

'); + }); + }, + + selectCategory: function(catId, catName) { + this.currentCategory = { id: catId, name: catName }; + this.loadParts(this.currentBrand, catId); + }, + + loadParts: function(brandName, categoryId) { + this.loading(true); + this.state = 'parts'; + this.setBreadcrumb( + 'Marcas › ' + + '' + escapeHtml(brandName) + ' › ' + + '' + escapeHtml(this.currentCategory.name) + '' + ); + fetch('/pos/api/catalog/brand-parts?brand=' + encodeURIComponent(brandName) + '&category_id=' + encodeURIComponent(categoryId)) + .then(r => r.json()) + .then(data => { + this.loading(false); + this._lastItems = data.items || []; + if (!data.items || !data.items.length) { + this.setContent('

No se encontraron refacciones.

'); + 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); + }) + .catch(err => { + this.loading(false); + this.setContent('

Error al cargar refacciones: ' + escapeHtml(err.message) + '

'); + }); + }, + + addToCart: function(partId, event) { + if (event) event.stopPropagation(); + const part = this._lastItems.find(function(p) { return p.id === partId; }); + if (!part) { + alert('Error: no se encontro la refaccion'); + return; + } + if (window.CatalogApp && CatalogApp.addToCart) { + CatalogApp.addToCart({ + id: part.id, + part_number: part.oem_part_number || 'N/A', + name: part.name || 'Refaccion', + brand: '', + price: part.local_price || 0, + tax_rate: 0.16, + unit: 'PZA', + stock: part.local_stock || 0, + source: 'oem-brand', + inventory_id: null + }, 1); + const btn = event.target; + const oldText = btn.textContent; + btn.textContent = 'Agregado!'; + btn.style.background = 'var(--color-success)'; + setTimeout(function() { btn.textContent = oldText; btn.style.background = ''; }, 1500); + return; + } + alert('Carrito no disponible. Asegurate de que la pagina haya cargado completamente.'); + } + }; + + function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + window.BrandCatalog = BrandCatalog; +})(); diff --git a/pos/templates/catalog.html b/pos/templates/catalog.html index 721435f..d36b272 100644 --- a/pos/templates/catalog.html +++ b/pos/templates/catalog.html @@ -107,9 +107,10 @@ Catalogo
-
+
+
+ + + @@ -280,5 +294,6 @@ +