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
This commit is contained in:
@@ -591,3 +591,158 @@ def _match_vin_to_catalog(master_conn, vin_info):
|
|||||||
return None
|
return None
|
||||||
finally:
|
finally:
|
||||||
cur.close()
|
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)
|
||||||
|
|||||||
188
pos/static/js/brand-catalog.js
Normal file
188
pos/static/js/brand-catalog.js
Normal file
@@ -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('<strong>Marcas de vehiculo</strong>');
|
||||||
|
fetch('/pos/api/catalog/vehicle-brands')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
this.loading(false);
|
||||||
|
if (!data.brands || !data.brands.length) {
|
||||||
|
this.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-text-muted);">No se encontraron marcas.</p>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html = '';
|
||||||
|
data.brands.forEach(b => {
|
||||||
|
html += '<div class="catalog-category-card" onclick="BrandCatalog.selectBrand(' + JSON.stringify(b.name) + ')">' +
|
||||||
|
'<div style="font-size:var(--text-h4);font-family:var(--font-heading);margin-bottom:4px;">' + escapeHtml(b.name) + '</div>' +
|
||||||
|
'<div style="font-size:var(--text-body-sm);color:var(--color-text-muted);">' + (b.part_count || 0) + ' refacciones</div>' +
|
||||||
|
'</div>';
|
||||||
|
});
|
||||||
|
this.setContent(html);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
this.loading(false);
|
||||||
|
this.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-error);">Error al cargar marcas: ' + escapeHtml(err.message) + '</p>');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
selectBrand: function(brandName) {
|
||||||
|
this.currentBrand = brandName;
|
||||||
|
this.loadCategories(brandName);
|
||||||
|
},
|
||||||
|
|
||||||
|
loadCategories: function(brandName) {
|
||||||
|
this.loading(true);
|
||||||
|
this.state = 'categories';
|
||||||
|
this.setBreadcrumb(
|
||||||
|
'<a href="javascript:void(0)" onclick="BrandCatalog.loadBrands()" style="color:var(--color-primary);text-decoration:none;">Marcas</a> › <strong>' + escapeHtml(brandName) + '</strong>'
|
||||||
|
);
|
||||||
|
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('<p style="grid-column:1/-1;text-align:center;color:var(--color-text-muted);">No se encontraron categorias para esta marca.</p>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html = '';
|
||||||
|
data.categories.forEach(c => {
|
||||||
|
html += '<div class="catalog-category-card" onclick="BrandCatalog.selectCategory(' + c.id + ', ' + JSON.stringify(c.name) + ')">' +
|
||||||
|
'<div style="font-size:var(--text-h4);font-family:var(--font-heading);margin-bottom:4px;">' + escapeHtml(c.name) + '</div>' +
|
||||||
|
'<div style="font-size:var(--text-body-sm);color:var(--color-text-muted);">' + c.part_count + ' refacciones</div>' +
|
||||||
|
'</div>';
|
||||||
|
});
|
||||||
|
this.setContent(html);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
this.loading(false);
|
||||||
|
this.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-error);">Error al cargar categorias: ' + escapeHtml(err.message) + '</p>');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
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(
|
||||||
|
'<a href="javascript:void(0)" onclick="BrandCatalog.loadBrands()" style="color:var(--color-primary);text-decoration:none;">Marcas</a> › ' +
|
||||||
|
'<a href="javascript:void(0)" onclick="BrandCatalog.selectBrand(' + JSON.stringify(brandName) + ')" style="color:var(--color-primary);text-decoration:none;">' + escapeHtml(brandName) + '</a> › ' +
|
||||||
|
'<strong>' + escapeHtml(this.currentCategory.name) + '</strong>'
|
||||||
|
);
|
||||||
|
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('<p style="grid-column:1/-1;text-align:center;color:var(--color-text-muted);">No se encontraron refacciones.</p>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html = '<div style="grid-column:1/-1;display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:var(--space-3);">';
|
||||||
|
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 += '<div class="catalog-category-card" style="padding:0;overflow:hidden;">' +
|
||||||
|
'<div style="height:160px;background:#f5f5f5;display:flex;align-items:center;justify-content:center;">' +
|
||||||
|
'<img src="' + escapeHtml(img) + '" alt="" style="max-width:100%;max-height:100%;object-fit:contain;">' +
|
||||||
|
'</div>' +
|
||||||
|
'<div style="padding:var(--space-3);">' +
|
||||||
|
'<div style="font-weight:600;font-size:var(--text-body);margin-bottom:4px;">' + escapeHtml(p.oem_part_number || 'N/A') + '</div>' +
|
||||||
|
'<div style="font-size:var(--text-body-sm);color:var(--color-text-muted);margin-bottom:8px;min-height:36px;">' + escapeHtml(p.name || '') + '</div>' +
|
||||||
|
'<div style="font-size:var(--text-h5);font-weight:700;color:var(--color-primary);margin-bottom:8px;">' + price + '</div>' +
|
||||||
|
'<button class="btn btn--primary btn--sm" style="width:100%;" onclick="BrandCatalog.addToCart(' + p.id + ', event)">Agregar</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
this.setContent(html);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
this.loading(false);
|
||||||
|
this.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-error);">Error al cargar refacciones: ' + escapeHtml(err.message) + '</p>');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
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;
|
||||||
|
})();
|
||||||
@@ -107,9 +107,10 @@
|
|||||||
<span class="breadcrumb__current">Catalogo</span>
|
<span class="breadcrumb__current">Catalogo</span>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="header-actions" style="position:relative;">
|
<div class="header-actions" style="position:relative;">
|
||||||
<div class="mode-toggle" id="modeToggle" title="Cambiar entre catalogo OEM (TecDoc), marcas locales y consumibles">
|
<div class="mode-toggle" id="modeToggle" title="Cambiar entre catalogo OEM (TecDoc), marcas locales, por marca de vehiculo y consumibles">
|
||||||
<button data-mode="oem" onclick="CatalogApp.setMode('oem')" disabled style="opacity:0.5;cursor:not-allowed;" title="Próximamente">OEM 🔒</button>
|
<button data-mode="oem" onclick="CatalogApp.setMode('oem')" disabled style="opacity:0.5;cursor:not-allowed;" title="Próximamente">OEM 🔒</button>
|
||||||
<button data-mode="local" onclick="CatalogApp.setMode('local')">Local</button>
|
<button data-mode="local" onclick="CatalogApp.setMode('local')">Local</button>
|
||||||
|
<button data-mode="brand" onclick="BrandCatalog.show()" title="Catalogo por marca de vehiculo">Por Marca</button>
|
||||||
<button data-mode="supplies" onclick="CatalogApp.setMode('supplies')" title="Aceites, quimicos, herramientas — sin vehiculo">Supplies</button>
|
<button data-mode="supplies" onclick="CatalogApp.setMode('supplies')" title="Aceites, quimicos, herramientas — sin vehiculo">Supplies</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="search-bar" id="searchBar">
|
<div class="search-bar" id="searchBar">
|
||||||
@@ -268,6 +269,19 @@
|
|||||||
<button class="banner__dismiss" onclick="document.getElementById('offlineBanner').style.display='none'" aria-label="Cerrar">×</button>
|
<button class="banner__dismiss" onclick="document.getElementById('offlineBanner').style.display='none'" aria-label="Cerrar">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Brand Catalog Overlay (full-screen overlay for brand-first browsing) -->
|
||||||
|
<div id="brandCatalogOverlay" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;z-index:9000;background:var(--color-bg-base);overflow:auto;padding:var(--space-4);">
|
||||||
|
<div style="max-width:1200px;margin:0 auto;">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-4);">
|
||||||
|
<h2 style="margin:0;font-family:var(--font-heading);font-size:var(--text-h3);">Catalogo por Marca</h2>
|
||||||
|
<button onclick="BrandCatalog.hide()" class="btn btn--sm" style="background:none;border:1px solid var(--color-border);color:var(--color-text-primary);padding:8px 16px;border-radius:var(--radius-md);cursor:pointer;">✕ Cerrar</button>
|
||||||
|
</div>
|
||||||
|
<div id="brandCatalogBreadcrumb" style="margin-bottom:var(--space-3);color:var(--color-text-muted);font-size:var(--text-body-sm);"></div>
|
||||||
|
<div id="brandCatalogLoading" style="display:none;text-align:center;padding:var(--space-8);"><div class="spinner"></div></div>
|
||||||
|
<div id="brandCatalogContent" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:var(--space-3);"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="/pos/static/js/i18n.js" defer></script>
|
<script src="/pos/static/js/i18n.js" defer></script>
|
||||||
<script src="/pos/static/js/kiosk.js" defer></script>
|
<script src="/pos/static/js/kiosk.js" defer></script>
|
||||||
<script src="/pos/static/js/app-init.js" defer></script>
|
<script src="/pos/static/js/app-init.js" defer></script>
|
||||||
@@ -280,5 +294,6 @@
|
|||||||
<script src="/pos/static/js/onboarding.js" defer></script>
|
<script src="/pos/static/js/onboarding.js" defer></script>
|
||||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||||
<script src="/pos/static/js/pwa-install.js" defer></script>
|
<script src="/pos/static/js/pwa-install.js" defer></script>
|
||||||
|
<script src="/pos/static/js/brand-catalog.js?v=1" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user