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
This commit is contained in:
@@ -663,9 +663,10 @@ def brand_categories():
|
|||||||
@catalog_bp.route('/brand-parts', methods=['GET'])
|
@catalog_bp.route('/brand-parts', methods=['GET'])
|
||||||
@require_auth('catalog.view')
|
@require_auth('catalog.view')
|
||||||
def brand_parts():
|
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', '')
|
brand = request.args.get('brand', '')
|
||||||
category_id = request.args.get('category_id', type=int)
|
category_id = request.args.get('category_id', type=int)
|
||||||
|
search = request.args.get('search', '').strip()
|
||||||
limit = request.args.get('limit', 50, type=int)
|
limit = request.args.get('limit', 50, type=int)
|
||||||
offset = request.args.get('offset', 0, type=int)
|
offset = request.args.get('offset', 0, type=int)
|
||||||
|
|
||||||
@@ -675,13 +676,22 @@ def brand_parts():
|
|||||||
def _query(master, tenant, branch_id):
|
def _query(master, tenant, branch_id):
|
||||||
cur = master.cursor()
|
cur = master.cursor()
|
||||||
try:
|
try:
|
||||||
# Get parts from the brand catalog
|
# Build dynamic filters
|
||||||
params = [brand]
|
params = [brand]
|
||||||
cat_filter = ""
|
cat_filter = ""
|
||||||
|
search_filter = ""
|
||||||
|
|
||||||
if category_id:
|
if category_id:
|
||||||
cat_filter = "AND pc.id_part_category = %s"
|
cat_filter = "AND pc.id_part_category = %s"
|
||||||
params.append(category_id)
|
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"""
|
cur.execute(f"""
|
||||||
SELECT DISTINCT p.id_part, p.oem_part_number,
|
SELECT DISTINCT p.id_part, p.oem_part_number,
|
||||||
COALESCE(NULLIF(p.name_es, ''), p.name_part) as name,
|
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
|
JOIN part_categories pc ON pc.id_part_category = pg.category_id
|
||||||
WHERE pvp.name_brand = %s
|
WHERE pvp.name_brand = %s
|
||||||
{cat_filter}
|
{cat_filter}
|
||||||
|
{search_filter}
|
||||||
ORDER BY p.id_part
|
ORDER BY p.id_part
|
||||||
LIMIT %s OFFSET %s
|
LIMIT %s OFFSET %s
|
||||||
""", params + [limit, offset])
|
""", query_params + [limit, offset])
|
||||||
|
|
||||||
part_rows = cur.fetchall()
|
part_rows = cur.fetchall()
|
||||||
part_ids = [r[0] for r in part_rows]
|
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
|
JOIN part_categories pc ON pc.id_part_category = pg.category_id
|
||||||
WHERE pvp.name_brand = %s
|
WHERE pvp.name_brand = %s
|
||||||
{cat_filter}
|
{cat_filter}
|
||||||
|
{search_filter}
|
||||||
""", params)
|
""", params)
|
||||||
total = cur.fetchone()[0]
|
total = cur.fetchone()[0]
|
||||||
|
|
||||||
@@ -738,6 +750,7 @@ def brand_parts():
|
|||||||
return jsonify({
|
return jsonify({
|
||||||
'brand': brand,
|
'brand': brand,
|
||||||
'category_id': category_id,
|
'category_id': category_id,
|
||||||
|
'search': search,
|
||||||
'items': items,
|
'items': items,
|
||||||
'total': total,
|
'total': total,
|
||||||
'limit': limit,
|
'limit': limit,
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
currentCategory: null,
|
currentCategory: null,
|
||||||
state: 'brands',
|
state: 'brands',
|
||||||
_lastItems: [],
|
_lastItems: [],
|
||||||
|
_allBrands: [],
|
||||||
|
_offset: 0,
|
||||||
|
_limit: 50,
|
||||||
|
_total: 0,
|
||||||
|
|
||||||
el: function(id) { return document.getElementById(id); },
|
el: function(id) { return document.getElementById(id); },
|
||||||
|
|
||||||
@@ -24,6 +28,9 @@
|
|||||||
this.currentCategory = null;
|
this.currentCategory = null;
|
||||||
this.state = 'brands';
|
this.state = 'brands';
|
||||||
this._lastItems = [];
|
this._lastItems = [];
|
||||||
|
this._allBrands = [];
|
||||||
|
this._offset = 0;
|
||||||
|
this._total = 0;
|
||||||
},
|
},
|
||||||
|
|
||||||
loading: function(on) {
|
loading: function(on) {
|
||||||
@@ -38,6 +45,7 @@
|
|||||||
this.el('brandCatalogBreadcrumb').innerHTML = html;
|
this.el('brandCatalogBreadcrumb').innerHTML = html;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ---------- BRANDS ----------
|
||||||
loadBrands: function() {
|
loadBrands: function() {
|
||||||
this.loading(true);
|
this.loading(true);
|
||||||
this.state = 'brands';
|
this.state = 'brands';
|
||||||
@@ -46,18 +54,12 @@
|
|||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
this.loading(false);
|
this.loading(false);
|
||||||
if (!data.brands || !data.brands.length) {
|
this._allBrands = data.brands || [];
|
||||||
|
if (!this._allBrands.length) {
|
||||||
this.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-text-muted);">No se encontraron marcas.</p>');
|
this.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-text-muted);">No se encontraron marcas.</p>');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let html = '';
|
this.renderBrandList(this._allBrands);
|
||||||
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 => {
|
.catch(err => {
|
||||||
this.loading(false);
|
this.loading(false);
|
||||||
@@ -65,11 +67,40 @@
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
renderBrandList: function(brands) {
|
||||||
|
let html = '<div style="grid-column:1/-1;margin-bottom:var(--space-3);">' +
|
||||||
|
'<input type="text" id="brandSearchInput" placeholder="Buscar marca..." ' +
|
||||||
|
'style="width:100%;padding:10px 14px;border:1px solid var(--color-border);border-radius:var(--radius-md);' +
|
||||||
|
'font-size:var(--text-body);background:var(--color-surface);color:var(--color-text-primary);' +
|
||||||
|
'outline:none;" oninput="BrandCatalog.filterBrands(this.value)">' +
|
||||||
|
'</div>';
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
|
||||||
|
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) {
|
selectBrand: function(brandName) {
|
||||||
this.currentBrand = brandName;
|
this.currentBrand = brandName;
|
||||||
this.loadCategories(brandName);
|
this.loadCategories(brandName);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ---------- CATEGORIES ----------
|
||||||
loadCategories: function(brandName) {
|
loadCategories: function(brandName) {
|
||||||
this.loading(true);
|
this.loading(true);
|
||||||
this.state = 'categories';
|
this.state = 'categories';
|
||||||
@@ -81,7 +112,12 @@
|
|||||||
.then(data => {
|
.then(data => {
|
||||||
this.loading(false);
|
this.loading(false);
|
||||||
if (!data.categories || !data.categories.length) {
|
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>');
|
this.setContent(
|
||||||
|
'<div style="grid-column:1/-1;text-align:center;padding:var(--space-8);">' +
|
||||||
|
'<p style="color:var(--color-text-muted);font-size:var(--text-body-lg);">No se encontraron categorias para <strong>' + escapeHtml(brandName) + '</strong>.</p>' +
|
||||||
|
'<button class="btn btn--primary" style="margin-top:var(--space-3);" onclick="BrandCatalog.loadBrands()">Volver a marcas</button>' +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let html = '';
|
let html = '';
|
||||||
@@ -101,10 +137,12 @@
|
|||||||
|
|
||||||
selectCategory: function(catId, catName) {
|
selectCategory: function(catId, catName) {
|
||||||
this.currentCategory = { id: catId, name: 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.loading(true);
|
||||||
this.state = 'parts';
|
this.state = 'parts';
|
||||||
this.setBreadcrumb(
|
this.setBreadcrumb(
|
||||||
@@ -112,33 +150,23 @@
|
|||||||
'<a href="javascript:void(0)" onclick="BrandCatalog.selectBrand(' + JSON.stringify(brandName) + ')" style="color:var(--color-primary);text-decoration:none;">' + escapeHtml(brandName) + '</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>'
|
'<strong>' + escapeHtml(this.currentCategory.name) + '</strong>'
|
||||||
);
|
);
|
||||||
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(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
this.loading(false);
|
this.loading(false);
|
||||||
this._lastItems = data.items || [];
|
this._lastItems = data.items || [];
|
||||||
|
this._total = data.total || 0;
|
||||||
|
this._offset = data.offset || 0;
|
||||||
if (!data.items || !data.items.length) {
|
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>');
|
this.renderPartsList([], searchTerm);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let html = '<div style="grid-column:1/-1;display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:var(--space-3);">';
|
this.renderPartsList(data.items, searchTerm);
|
||||||
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 => {
|
.catch(err => {
|
||||||
this.loading(false);
|
this.loading(false);
|
||||||
@@ -146,6 +174,85 @@
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
renderPartsList: function(items, searchTerm) {
|
||||||
|
let html = '<div style="grid-column:1/-1;margin-bottom:var(--space-3);display:flex;gap:var(--space-2);flex-wrap:wrap;align-items:center;">' +
|
||||||
|
'<input type="text" id="partsSearchInput" placeholder="Buscar refaccion..." value="' + escapeHtml(searchTerm || '') + '" ' +
|
||||||
|
'style="flex:1;min-width:200px;padding:10px 14px;border:1px solid var(--color-border);border-radius:var(--radius-md);' +
|
||||||
|
'font-size:var(--text-body);background:var(--color-surface);color:var(--color-text-primary);outline:none;" ' +
|
||||||
|
'onkeydown="if(event.key===\'Enter\')BrandCatalog.searchParts(this.value)">' +
|
||||||
|
'<button class="btn btn--primary btn--sm" onclick="BrandCatalog.searchParts(document.getElementById(\'partsSearchInput\').value)">Buscar</button>' +
|
||||||
|
'<button class="btn btn--secondary btn--sm" onclick="BrandCatalog.clearPartsSearch()">Limpiar</button>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
html += '<div style="grid-column:1/-1;text-align:center;padding:var(--space-8);">' +
|
||||||
|
'<p style="color:var(--color-text-muted);font-size:var(--text-body-lg);">No se encontraron refacciones.</p>' +
|
||||||
|
'<button class="btn btn--primary" style="margin-top:var(--space-3);" onclick="BrandCatalog.loadCategories(' + JSON.stringify(this.currentBrand) + ')">Volver a categorias</button>' +
|
||||||
|
'</div>';
|
||||||
|
this.setContent(html);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats line
|
||||||
|
const startIdx = this._offset + 1;
|
||||||
|
const endIdx = this._offset + items.length;
|
||||||
|
html += '<div style="grid-column:1/-1;font-size:var(--text-body-sm);color:var(--color-text-muted);margin-bottom:var(--space-2);">' +
|
||||||
|
'Mostrando ' + startIdx + '-' + endIdx + ' de ' + this._total + ' refacciones' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
html += '<div style="grid-column:1/-1;display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:var(--space-3);">';
|
||||||
|
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
|
||||||
|
? '<span style="display:inline-block;background:var(--color-success);color:#fff;font-size:11px;padding:2px 8px;border-radius:var(--radius-sm);margin-left:6px;">' + p.local_stock + ' en stock</span>'
|
||||||
|
: '<span style="display:inline-block;background:var(--color-text-muted);color:#fff;font-size:11px;padding:2px 8px;border-radius:var(--radius-sm);margin-left:6px;">Sin stock local</span>';
|
||||||
|
html += '<div class="catalog-category-card" style="padding:0;overflow:hidden;display:flex;flex-direction:column;">' +
|
||||||
|
'<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);flex:1;display:flex;flex-direction:column;">' +
|
||||||
|
'<div style="font-weight:600;font-size:var(--text-body);margin-bottom:4px;">' + escapeHtml(p.oem_part_number || 'N/A') + stockBadge + '</div>' +
|
||||||
|
'<div style="font-size:var(--text-body-sm);color:var(--color-text-muted);margin-bottom:8px;flex:1;">' + 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>';
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
const hasPrev = this._offset > 0;
|
||||||
|
const hasNext = (this._offset + this._limit) < this._total;
|
||||||
|
html += '<div style="grid-column:1/-1;display:flex;justify-content:center;align-items:center;gap:var(--space-3);padding:var(--space-4) 0;">' +
|
||||||
|
'<button class="btn btn--secondary" ' + (hasPrev ? '' : 'disabled style="opacity:0.5;cursor:not-allowed;"') +
|
||||||
|
' onclick="BrandCatalog.goToPage(' + (this._offset - this._limit) + ')">← Anterior</button>' +
|
||||||
|
'<span style="font-size:var(--text-body-sm);color:var(--color-text-muted);">Pagina ' + (Math.floor(this._offset / this._limit) + 1) + ' de ' + (Math.ceil(this._total / this._limit) || 1) + '</span>' +
|
||||||
|
'<button class="btn btn--secondary" ' + (hasNext ? '' : 'disabled style="opacity:0.5;cursor:not-allowed;"') +
|
||||||
|
' onclick="BrandCatalog.goToPage(' + (this._offset + this._limit) + ')">Siguiente →</button>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
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) {
|
addToCart: function(partId, event) {
|
||||||
if (event) event.stopPropagation();
|
if (event) event.stopPropagation();
|
||||||
const part = this._lastItems.find(function(p) { return p.id === partId; });
|
const part = this._lastItems.find(function(p) { return p.id === partId; });
|
||||||
|
|||||||
Reference in New Issue
Block a user