5-task plan: catalog_service.py, catalog_bp.py rewrite (9 endpoints), catalog.html + catalog.js rewrite with hierarchical vehicle navigation, integration test. Performance-optimized for 14B+ vehicle_parts table. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
121 KiB
Catálogo POS — Navegación por Vehículo (TecDoc) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Rewrite the POS catalog to navigate the full TecDoc catalog (1.5M+ parts) by vehicle: Brand > Model > Year > Engine > Category > Subcategory > Parts. Each part shows local stock (tenant inventory), bodega availability (warehouse_inventory), and aftermarket alternatives. Includes smart search, detail panel, cart, and offline fallback.
Spec: /home/Autopartes/docs/plans/2026-04-01-catalog-vehiculo-design.md
Tech Stack: Python 3, Flask blueprint, psycopg2 (direct queries against nexus_autoparts + tenant DB), vanilla HTML/JS/CSS with tokens.css design system.
Depends on: Plan 1 Foundation (complete) — tenant_db (get_master_conn, get_tenant_conn), middleware (require_auth, g.tenant_id, g.branch_id), config (MASTER_DB_URL)
CRITICAL performance constraint: The vehicle_parts table has 14+ BILLION rows. Every query that touches this table MUST:
- Filter by indexed column
model_year_engine_id(index:idx_vehicle_parts_mye) - Use LIMIT clauses
- Never COUNT(*) or do full table scans
- Use EXISTS sub-selects instead of JOINs for existence checks where possible
Database Schema Reference
nexus_autoparts (master catalog)
brands: id_brand (PK), name_brand (UNIQUE), country, founded_year
models: id_model (PK), brand_id (FK→brands), name_model, generation
years: id_year (PK), year_car (UNIQUE)
engines: id_engine (PK), name_engine, displacement_cc, cylinders, engine_code
model_year_engine: id_mye (PK), model_id (FK), year_id (FK), engine_id (FK), trim_level
Indexes: idx_mye_model, idx_mye_year, idx_mye_engine, uq_mye_combo
vehicle_parts: id_vehicle_part (BIGINT PK), model_year_engine_id (FK→mye), part_id (FK→parts)
Indexes: idx_vehicle_parts_mye(model_year_engine_id), idx_vehicle_parts_part(part_id)
parts: id_part (PK), oem_part_number (UNIQUE), name_part, name_es, group_id (FK),
description, description_es, image_url, search_vector (tsvector)
Indexes: idx_parts_oem (UNIQUE), idx_parts_group, idx_parts_search (GIN)
part_categories: id_part_category (PK), name_part_category, name_es, parent_id, slug, tecdoc_id
part_groups: id_part_group (PK), category_id (FK→part_categories), name_part_group, name_es, tecdoc_id
part_cross_references: id_part_cross_ref (PK), part_id (FK), cross_reference_number, source_ref
aftermarket_parts: id_aftermarket_parts (PK), oem_part_id (FK→parts), manufacturer_id (FK),
part_number, name_aftermarket_parts, name_es, cost_usd
manufacturers: id_manufacture (PK), name_manufacture (UNIQUE)
warehouse_inventory: id_inventory (BIGINT PK), user_id (FK→users), part_id (FK→parts),
price NUMERIC(12,2), stock_quantity INT, warehouse_location
users: id_user (PK), business_name
Tenant DB (per refaccionaria)
inventory: id (PK), branch_id, part_number (VARCHAR 100), catalog_part_id (INT, nullable),
name, brand, price_1, price_2, price_3, cost, tax_rate, unit, barcode,
location, image_url, is_active
Indexes: idx_inventory_part(part_number), idx_inventory_branch_part(branch_id, part_number)
inventory_operations: id, inventory_id (FK→inventory), branch_id, quantity
Stock = SUM(quantity) WHERE inventory_id = X
File Structure
/home/Autopartes/pos/
├── services/
│ └── catalog_service.py # CREATE: all catalog queries (nexus_autoparts + stock enrichment)
├── blueprints/
│ └── catalog_bp.py # REWRITE: 9 endpoints for vehicle navigation + search
├── templates/
│ └── catalog.html # REWRITE: vehicle hierarchy UI, detail panel, cart
└── static/
└── js/
└── catalog.js # REWRITE: navigation state machine, search, cart, offline
Task 1: Catalog service
Files:
- Create:
/home/Autopartes/pos/services/catalog_service.py
Pure data-access module. Every function receives connection(s) as parameters — it NEVER imports tenant_db directly. The caller (blueprint) is responsible for opening/closing connections.
- Step 1: Create catalog_service.py with all functions
# /home/Autopartes/pos/services/catalog_service.py
"""Catalog service: queries nexus_autoparts (TecDoc) catalog with local stock enrichment.
All functions receive database connections as parameters.
This module NEVER imports tenant_db — the caller passes connections.
PERFORMANCE: vehicle_parts has 14B+ rows. Every query MUST:
- Filter by model_year_engine_id (indexed)
- Use LIMIT
- Use EXISTS instead of COUNT(*) on vehicle_parts where possible
"""
import re
# ─────────────────────────────────────────────────────────────────────────────
# VEHICLE HIERARCHY NAVIGATION
# ─────────────────────────────────────────────────────────────────────────────
def get_brands(master_conn):
"""Get all vehicle brands that have at least one part in the catalog.
Uses EXISTS on model_year_engine + vehicle_parts to avoid scanning
vehicle_parts fully. The subquery stops at the first match per brand.
"""
cur = master_conn.cursor()
cur.execute("""
SELECT DISTINCT b.id_brand, b.name_brand
FROM brands b
WHERE EXISTS (
SELECT 1
FROM models m
JOIN model_year_engine mye ON mye.model_id = m.id_model
JOIN vehicle_parts vp ON vp.model_year_engine_id = mye.id_mye
WHERE m.brand_id = b.id_brand
LIMIT 1
)
ORDER BY b.name_brand
""")
rows = cur.fetchall()
cur.close()
return [{'id_brand': r[0], 'name_brand': r[1]} for r in rows]
def get_models(master_conn, brand_id):
"""Get models for a brand that have at least one MYE with parts."""
cur = master_conn.cursor()
cur.execute("""
SELECT DISTINCT m.id_model, m.name_model
FROM models m
WHERE m.brand_id = %s
AND EXISTS (
SELECT 1
FROM model_year_engine mye
JOIN vehicle_parts vp ON vp.model_year_engine_id = mye.id_mye
WHERE mye.model_id = m.id_model
LIMIT 1
)
ORDER BY m.name_model
""", (brand_id,))
rows = cur.fetchall()
cur.close()
return [{'id_model': r[0], 'name_model': r[1]} for r in rows]
def get_years(master_conn, model_id):
"""Get distinct years for a model (via MYE) that have parts. Ordered DESC."""
cur = master_conn.cursor()
cur.execute("""
SELECT DISTINCT y.id_year, y.year_car
FROM years y
JOIN model_year_engine mye ON mye.year_id = y.id_year
WHERE mye.model_id = %s
AND EXISTS (
SELECT 1
FROM vehicle_parts vp
WHERE vp.model_year_engine_id = mye.id_mye
LIMIT 1
)
ORDER BY y.year_car DESC
""", (model_id,))
rows = cur.fetchall()
cur.close()
return [{'id_year': r[0], 'year_car': r[1]} for r in rows]
def get_engines(master_conn, model_id, year_id):
"""Get MYE entries (engine + trim) for a model+year combo that have parts."""
cur = master_conn.cursor()
cur.execute("""
SELECT mye.id_mye, e.name_engine, mye.trim_level
FROM model_year_engine mye
JOIN engines e ON e.id_engine = mye.engine_id
WHERE mye.model_id = %s AND mye.year_id = %s
AND EXISTS (
SELECT 1
FROM vehicle_parts vp
WHERE vp.model_year_engine_id = mye.id_mye
LIMIT 1
)
ORDER BY e.name_engine, mye.trim_level
""", (model_id, year_id))
rows = cur.fetchall()
cur.close()
return [{'id_mye': r[0], 'name_engine': r[1], 'trim_level': r[2] or ''} for r in rows]
def get_categories(master_conn, mye_id):
"""Get part categories that have parts for this vehicle (mye_id).
Uses a subquery on vehicle_parts filtered by mye_id (indexed),
then JOINs through parts → part_groups → part_categories.
Uses COUNT with a safety LIMIT on the subquery.
"""
cur = master_conn.cursor()
cur.execute("""
SELECT pc.id_part_category,
COALESCE(pc.name_es, pc.name_part_category) AS name,
sub.cnt
FROM (
SELECT pg.category_id, COUNT(*) AS cnt
FROM vehicle_parts vp
JOIN parts p ON p.id_part = vp.part_id
JOIN part_groups pg ON pg.id_part_group = p.group_id
WHERE vp.model_year_engine_id = %s
GROUP BY pg.category_id
) sub
JOIN part_categories pc ON pc.id_part_category = sub.category_id
ORDER BY name
""", (mye_id,))
rows = cur.fetchall()
cur.close()
return [{'id_part_category': r[0], 'name': r[1], 'part_count': r[2]} for r in rows]
def get_groups(master_conn, mye_id, category_id):
"""Get part groups (subcategories) for this vehicle + category, with part counts."""
cur = master_conn.cursor()
cur.execute("""
SELECT pg.id_part_group,
COALESCE(pg.name_es, pg.name_part_group) AS name,
COUNT(*) AS cnt
FROM vehicle_parts vp
JOIN parts p ON p.id_part = vp.part_id
JOIN part_groups pg ON pg.id_part_group = p.group_id
WHERE vp.model_year_engine_id = %s
AND pg.category_id = %s
GROUP BY pg.id_part_group, name
ORDER BY name
""", (mye_id, category_id))
rows = cur.fetchall()
cur.close()
return [{'id_part_group': r[0], 'name': r[1], 'part_count': r[2]} for r in rows]
# ─────────────────────────────────────────────────────────────────────────────
# PARTS LIST + DETAIL (with stock enrichment)
# ─────────────────────────────────────────────────────────────────────────────
def get_parts(master_conn, mye_id, group_id, tenant_conn, branch_id, page=1, per_page=30):
"""Get parts for a vehicle + part group, enriched with local stock + bodega indicator.
1. Fetch parts from nexus_autoparts (vehicle_parts + parts) — paginated
2. For each OEM number, look up tenant inventory for local stock
3. For each part_id, check warehouse_inventory for bodega availability
Returns: {data: [...], pagination: {...}}
"""
per_page = min(per_page, 100)
offset = (page - 1) * per_page
cur = master_conn.cursor()
# Count total (bounded — uses indexed mye_id + group_id join)
cur.execute("""
SELECT COUNT(*)
FROM vehicle_parts vp
JOIN parts p ON p.id_part = vp.part_id
WHERE vp.model_year_engine_id = %s AND p.group_id = %s
""", (mye_id, group_id))
total = cur.fetchone()[0]
# Fetch page of parts
cur.execute("""
SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es,
p.description, p.description_es, p.image_url
FROM vehicle_parts vp
JOIN parts p ON p.id_part = vp.part_id
WHERE vp.model_year_engine_id = %s AND p.group_id = %s
ORDER BY p.name_part
LIMIT %s OFFSET %s
""", (mye_id, group_id, per_page, offset))
rows = cur.fetchall()
if not rows:
cur.close()
return {'data': [], 'pagination': _pagination(page, per_page, total)}
part_ids = [r[0] for r in rows]
oem_numbers = [r[1] for r in rows]
# Bodega availability: count distinct bodegas with stock > 0 per part
cur.execute("""
SELECT part_id, COUNT(*) AS bodega_count
FROM warehouse_inventory
WHERE part_id = ANY(%s) AND stock_quantity > 0
GROUP BY part_id
""", (part_ids,))
bodega_map = {r[0]: r[1] for r in cur.fetchall()}
cur.close()
# Local stock enrichment from tenant DB
local_map = _get_local_stock_bulk(tenant_conn, branch_id, oem_numbers, part_ids)
items = []
for r in rows:
part_id = r[0]
oem = r[1]
local = local_map.get(oem) or local_map.get(f'cat:{part_id}')
items.append({
'id_part': part_id,
'oem_part_number': oem,
'name': r[3] or r[2], # prefer Spanish name
'description': r[5] or r[4],
'image_url': r[6],
'local_stock': local['stock'] if local else 0,
'local_price': local['price_1'] if local else None,
'bodega_count': bodega_map.get(part_id, 0),
})
return {'data': items, 'pagination': _pagination(page, per_page, total)}
def get_part_detail(master_conn, part_id, tenant_conn, branch_id):
"""Get full detail for a single part: catalog info, local stock, bodegas, alternatives.
Returns:
{
part: {id, oem, name, description, image_url, group_name, category_name},
local: {stock, price_1, price_2, price_3, cost, inventory_id} | null,
bodegas: [{business_name, price, stock, location}],
alternatives: [{part_number, manufacturer, name, type, local_stock, bodega_count}]
}
"""
cur = master_conn.cursor()
# Part info with group + category names
cur.execute("""
SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es,
p.description, p.description_es, p.image_url,
COALESCE(pg.name_es, pg.name_part_group) AS group_name,
COALESCE(pc.name_es, pc.name_part_category) AS category_name
FROM parts p
LEFT JOIN part_groups pg ON pg.id_part_group = p.group_id
LEFT JOIN part_categories pc ON pc.id_part_category = pg.category_id
WHERE p.id_part = %s
""", (part_id,))
row = cur.fetchone()
if not row:
cur.close()
return None
oem = row[1]
part_info = {
'id_part': row[0],
'oem_part_number': oem,
'name': row[3] or row[2],
'description': row[5] or row[4],
'image_url': row[6],
'group_name': row[7],
'category_name': row[8],
}
# Bodegas with stock
cur.execute("""
SELECT u.business_name, wi.price, wi.stock_quantity, wi.warehouse_location
FROM warehouse_inventory wi
JOIN users u ON u.id_user = wi.user_id
WHERE wi.part_id = %s AND wi.stock_quantity > 0
ORDER BY wi.price ASC
LIMIT 20
""", (part_id,))
bodegas = [
{'business_name': r[0], 'price': float(r[1]) if r[1] else None,
'stock': r[2], 'location': r[3]}
for r in cur.fetchall()
]
# Alternatives: cross-references + aftermarket
alternatives = _get_alternatives(cur, part_id)
cur.close()
# Local stock
local = _get_local_stock_single(tenant_conn, branch_id, oem, part_id)
# Enrich alternatives with local stock + bodega count
if alternatives:
alt_oems = [a['part_number'] for a in alternatives]
alt_local = _get_local_stock_bulk(tenant_conn, branch_id, alt_oems, [])
cur2 = master_conn.cursor()
# Find part_ids for cross-ref numbers to check bodega stock
cur2.execute("""
SELECT oem_part_number, id_part FROM parts
WHERE oem_part_number = ANY(%s)
""", (alt_oems,))
oem_to_part = {r[0]: r[1] for r in cur2.fetchall()}
alt_part_ids = [pid for pid in oem_to_part.values() if pid]
bodega_map = {}
if alt_part_ids:
cur2.execute("""
SELECT part_id, COUNT(*)
FROM warehouse_inventory
WHERE part_id = ANY(%s) AND stock_quantity > 0
GROUP BY part_id
""", (alt_part_ids,))
bodega_map = {r[0]: r[1] for r in cur2.fetchall()}
cur2.close()
for a in alternatives:
l = alt_local.get(a['part_number'])
a['local_stock'] = l['stock'] if l else 0
pid = oem_to_part.get(a['part_number'])
a['bodega_count'] = bodega_map.get(pid, 0) if pid else 0
return {
'part': part_info,
'local': local,
'bodegas': bodegas,
'alternatives': alternatives,
}
def _get_alternatives(cur, part_id):
"""Get cross-references + aftermarket parts for a given OEM part."""
results = []
# Cross-references (other OEM numbers that reference this part)
cur.execute("""
SELECT pcr.cross_reference_number, pcr.source_ref
FROM part_cross_references pcr
WHERE pcr.part_id = %s
LIMIT 50
""", (part_id,))
for r in cur.fetchall():
results.append({
'part_number': r[0],
'manufacturer': r[1] or 'OEM Cross-Ref',
'name': None,
'type': 'cross_reference',
'local_stock': 0,
'bodega_count': 0,
})
# Aftermarket alternatives
cur.execute("""
SELECT ap.part_number, m.name_manufacture,
COALESCE(ap.name_es, ap.name_aftermarket_parts) AS name
FROM aftermarket_parts ap
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
WHERE ap.oem_part_id = %s
LIMIT 50
""", (part_id,))
for r in cur.fetchall():
results.append({
'part_number': r[0],
'manufacturer': r[1],
'name': r[2],
'type': 'aftermarket',
'local_stock': 0,
'bodega_count': 0,
})
return results
# ─────────────────────────────────────────────────────────────────────────────
# SMART SEARCH
# ─────────────────────────────────────────────────────────────────────────────
def smart_search(master_conn, q, tenant_conn, branch_id, limit=50):
"""Search parts by part number or text. Enriches with local stock.
Strategy:
- If q looks like a part number (contains digits + hyphens): search oem_part_number ILIKE
- If q is text: use PostgreSQL full-text search (search_vector) with ILIKE fallback
- Always enriches results with local stock from tenant DB
"""
q = q.strip()
if not q or len(q) < 2:
return []
limit = min(limit, 100)
cur = master_conn.cursor()
is_part_number = bool(re.search(r'\d.*[-/]|[-/].*\d|\d{3,}', q))
if is_part_number:
# Search by OEM part number
clean_q = q.replace(' ', '').upper()
cur.execute("""
SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es,
p.image_url, p.group_id
FROM parts p
WHERE REPLACE(UPPER(p.oem_part_number), ' ', '') LIKE %s
ORDER BY p.oem_part_number
LIMIT %s
""", (f'%{clean_q}%', limit))
else:
# Full-text search using tsvector, fall back to ILIKE
tsquery = ' & '.join(q.split())
cur.execute("""
SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es,
p.image_url, p.group_id
FROM parts p
WHERE p.search_vector @@ to_tsquery('spanish', %s)
OR p.name_part ILIKE %s
OR p.name_es ILIKE %s
ORDER BY
CASE WHEN p.search_vector @@ to_tsquery('spanish', %s)
THEN 0 ELSE 1 END,
p.name_part
LIMIT %s
""", (tsquery, f'%{q}%', f'%{q}%', tsquery, limit))
rows = cur.fetchall()
if not rows:
cur.close()
return []
part_ids = [r[0] for r in rows]
oem_numbers = [r[1] for r in rows]
# Get vehicle info for each part (first match only)
vehicle_info_map = {}
cur.execute("""
SELECT DISTINCT ON (vp.part_id)
vp.part_id, b.name_brand, m.name_model, y.year_car
FROM vehicle_parts vp
JOIN model_year_engine mye ON mye.id_mye = vp.model_year_engine_id
JOIN models m ON m.id_model = mye.model_id
JOIN brands b ON b.id_brand = m.brand_id
JOIN years y ON y.id_year = mye.year_id
WHERE vp.part_id = ANY(%s)
ORDER BY vp.part_id, y.year_car DESC
""", (part_ids,))
for r in cur.fetchall():
vehicle_info_map[r[0]] = f"{r[1]} {r[2]} {r[3]}"
cur.close()
# Local stock enrichment
local_map = _get_local_stock_bulk(tenant_conn, branch_id, oem_numbers, part_ids)
results = []
for r in rows:
part_id = r[0]
oem = r[1]
local = local_map.get(oem) or local_map.get(f'cat:{part_id}')
results.append({
'id_part': part_id,
'oem_part_number': oem,
'name': r[3] or r[2],
'image_url': r[4],
'local_stock': local['stock'] if local else 0,
'local_price': local['price_1'] if local else None,
'vehicle_info': vehicle_info_map.get(part_id, ''),
})
return results
# ─────────────────────────────────────────────────────────────────────────────
# LOCAL STOCK HELPERS
# ─────────────────────────────────────────────────────────────────────────────
def _get_local_stock_bulk(tenant_conn, branch_id, oem_numbers, catalog_part_ids):
"""Look up tenant inventory for a batch of OEM numbers / catalog part IDs.
Returns: dict keyed by oem_number (or 'cat:{id}') → {stock, price_1, ...}
Matches by: part_number = oem_number OR catalog_part_id = id
"""
if not oem_numbers and not catalog_part_ids:
return {}
cur = tenant_conn.cursor()
conditions = []
params = []
if oem_numbers:
conditions.append("i.part_number = ANY(%s)")
params.append(oem_numbers)
if catalog_part_ids:
conditions.append("i.catalog_part_id = ANY(%s)")
params.append(catalog_part_ids)
where = " OR ".join(conditions)
branch_filter = ""
if branch_id:
branch_filter = " AND i.branch_id = %s"
params.append(branch_id)
cur.execute(f"""
SELECT i.id, i.part_number, i.catalog_part_id,
i.price_1, i.price_2, i.price_3, i.cost, i.tax_rate,
COALESCE(SUM(io.quantity), 0) AS stock
FROM inventory i
LEFT JOIN inventory_operations io ON io.inventory_id = i.id
WHERE ({where}) AND i.is_active = true{branch_filter}
GROUP BY i.id
""", params)
result = {}
for r in cur.fetchall():
entry = {
'inventory_id': r[0],
'part_number': r[1],
'catalog_part_id': r[2],
'price_1': float(r[3]) if r[3] else 0,
'price_2': float(r[4]) if r[4] else 0,
'price_3': float(r[5]) if r[5] else 0,
'cost': float(r[6]) if r[6] else 0,
'tax_rate': float(r[7]) if r[7] else 0.16,
'stock': r[8],
}
if r[1]:
result[r[1]] = entry
if r[2]:
result[f'cat:{r[2]}'] = entry
cur.close()
return result
def _get_local_stock_single(tenant_conn, branch_id, oem_part_number, catalog_part_id):
"""Look up a single part in the tenant inventory. Returns dict or None."""
cur = tenant_conn.cursor()
branch_filter = ""
params = [oem_part_number, catalog_part_id]
if branch_id:
branch_filter = " AND i.branch_id = %s"
params.append(branch_id)
cur.execute(f"""
SELECT i.id, i.price_1, i.price_2, i.price_3, i.cost, i.tax_rate,
i.location, i.unit, i.barcode,
COALESCE(SUM(io.quantity), 0) AS stock
FROM inventory i
LEFT JOIN inventory_operations io ON io.inventory_id = i.id
WHERE (i.part_number = %s OR i.catalog_part_id = %s)
AND i.is_active = true{branch_filter}
GROUP BY i.id
LIMIT 1
""", params)
row = cur.fetchone()
cur.close()
if not row:
return None
return {
'inventory_id': row[0],
'price_1': float(row[1]) if row[1] else 0,
'price_2': float(row[2]) if row[2] else 0,
'price_3': float(row[3]) if row[3] else 0,
'cost': float(row[4]) if row[4] else 0,
'tax_rate': float(row[5]) if row[5] else 0.16,
'location': row[6],
'unit': row[7] or 'PZA',
'barcode': row[8],
'stock': row[9],
}
# ─────────────────────────────────────────────────────────────────────────────
# BODEGA AVAILABILITY (standalone)
# ─────────────────────────────────────────────────────────────────────────────
def get_bodega_availability(master_conn, part_id):
"""Check warehouse_inventory for a part. Returns list of bodegas with stock."""
cur = master_conn.cursor()
cur.execute("""
SELECT u.business_name, wi.price, wi.stock_quantity, wi.warehouse_location
FROM warehouse_inventory wi
JOIN users u ON u.id_user = wi.user_id
WHERE wi.part_id = %s AND wi.stock_quantity > 0
ORDER BY wi.price ASC
LIMIT 20
""", (part_id,))
rows = cur.fetchall()
cur.close()
return [
{'business_name': r[0], 'price': float(r[1]) if r[1] else None,
'stock': r[2], 'location': r[3]}
for r in rows
]
# ─────────────────────────────────────────────────────────────────────────────
# HELPERS
# ─────────────────────────────────────────────────────────────────────────────
def _pagination(page, per_page, total):
"""Build standard pagination dict."""
total_pages = max(1, (total + per_page - 1) // per_page)
return {
'page': page,
'per_page': per_page,
'total': total,
'total_pages': total_pages,
}
Task 2: Catalog blueprint rewrite
Files:
- Rewrite:
/home/Autopartes/pos/blueprints/catalog_bp.py
Complete rewrite. Replaces ALL existing endpoints with 9 new ones for TecDoc vehicle navigation. Each endpoint opens both connections (master + tenant), calls catalog_service, and returns JSON.
- Step 1: Rewrite catalog_bp.py
# /home/Autopartes/pos/blueprints/catalog_bp.py
"""Catalog blueprint: TecDoc vehicle navigation with local stock enrichment.
Endpoints (all under /pos/api/catalog):
GET /brands — vehicle brands with parts
GET /models?brand_id= — models for a brand
GET /years?model_id= — years for a model
GET /engines?model_id=&year_id= — engines for model+year
GET /categories?mye_id= — part categories for vehicle
GET /groups?mye_id=&category_id= — part subcategories for vehicle+category
GET /parts?mye_id=&group_id= — parts with local stock enrichment
GET /part/<part_id> — full part detail (stock + bodegas + alternatives)
GET /search?q= — smart search (part number or text)
"""
from flask import Blueprint, request, jsonify, g
from middleware import require_auth
from tenant_db import get_master_conn, get_tenant_conn
from services import catalog_service
catalog_bp = Blueprint('catalog', __name__, url_prefix='/pos/api/catalog')
def _with_conns(fn):
"""Helper: open master + tenant connections, call fn, close both.
fn receives (master_conn, tenant_conn, branch_id).
"""
master = None
tenant = None
try:
master = get_master_conn()
tenant = get_tenant_conn(g.tenant_id)
branch_id = request.args.get('branch_id', g.branch_id)
return fn(master, tenant, branch_id)
except Exception as e:
return jsonify({'error': str(e)}), 500
finally:
if master:
try: master.close()
except: pass
if tenant:
try: tenant.close()
except: pass
def _master_only(fn):
"""Helper: open only master connection for hierarchy endpoints."""
master = None
try:
master = get_master_conn()
return fn(master)
except Exception as e:
return jsonify({'error': str(e)}), 500
finally:
if master:
try: master.close()
except: pass
# ─── Hierarchy navigation (master DB only) ───
@catalog_bp.route('/brands', methods=['GET'])
@require_auth('catalog.view')
def brands():
def _do(master):
data = catalog_service.get_brands(master)
return jsonify({'data': data})
return _master_only(_do)
@catalog_bp.route('/models', methods=['GET'])
@require_auth('catalog.view')
def models():
brand_id = request.args.get('brand_id', type=int)
if not brand_id:
return jsonify({'error': 'brand_id required'}), 400
def _do(master):
data = catalog_service.get_models(master, brand_id)
return jsonify({'data': data})
return _master_only(_do)
@catalog_bp.route('/years', methods=['GET'])
@require_auth('catalog.view')
def years():
model_id = request.args.get('model_id', type=int)
if not model_id:
return jsonify({'error': 'model_id required'}), 400
def _do(master):
data = catalog_service.get_years(master, model_id)
return jsonify({'data': data})
return _master_only(_do)
@catalog_bp.route('/engines', methods=['GET'])
@require_auth('catalog.view')
def engines():
model_id = request.args.get('model_id', type=int)
year_id = request.args.get('year_id', type=int)
if not model_id or not year_id:
return jsonify({'error': 'model_id and year_id required'}), 400
def _do(master):
data = catalog_service.get_engines(master, model_id, year_id)
return jsonify({'data': data})
return _master_only(_do)
@catalog_bp.route('/categories', methods=['GET'])
@require_auth('catalog.view')
def categories():
mye_id = request.args.get('mye_id', type=int)
if not mye_id:
return jsonify({'error': 'mye_id required'}), 400
def _do(master):
data = catalog_service.get_categories(master, mye_id)
return jsonify({'data': data})
return _master_only(_do)
@catalog_bp.route('/groups', methods=['GET'])
@require_auth('catalog.view')
def groups():
mye_id = request.args.get('mye_id', type=int)
category_id = request.args.get('category_id', type=int)
if not mye_id or not category_id:
return jsonify({'error': 'mye_id and category_id required'}), 400
def _do(master):
data = catalog_service.get_groups(master, mye_id, category_id)
return jsonify({'data': data})
return _master_only(_do)
# ─── Parts with stock enrichment (master + tenant) ───
@catalog_bp.route('/parts', methods=['GET'])
@require_auth('catalog.view')
def parts():
mye_id = request.args.get('mye_id', type=int)
group_id = request.args.get('group_id', type=int)
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 30, type=int)
if not mye_id or not group_id:
return jsonify({'error': 'mye_id and group_id required'}), 400
def _do(master, tenant, branch_id):
result = catalog_service.get_parts(master, mye_id, group_id, tenant, branch_id, page, per_page)
return jsonify(result)
return _with_conns(_do)
@catalog_bp.route('/part/<int:part_id>', methods=['GET'])
@require_auth('catalog.view')
def part_detail(part_id):
def _do(master, tenant, branch_id):
result = catalog_service.get_part_detail(master, part_id, tenant, branch_id)
if not result:
return jsonify({'error': 'Part not found'}), 404
return jsonify(result)
return _with_conns(_do)
@catalog_bp.route('/search', methods=['GET'])
@require_auth('catalog.view')
def search():
q = request.args.get('q', '').strip()
if not q or len(q) < 2:
return jsonify({'data': []})
limit = request.args.get('limit', 50, type=int)
def _do(master, tenant, branch_id):
data = catalog_service.smart_search(master, q, tenant, branch_id, limit)
return jsonify({'data': data})
return _with_conns(_do)
Task 3: Catalog HTML rewrite
Files:
- Rewrite:
/home/Autopartes/pos/templates/catalog.html
Complete rewrite of the page. Keeps: sidebar navigation, cart sidebar, cart FAB, offline banner, theme switcher, tokens.css. Replaces: search panel + product grid with vehicle hierarchy navigation + breadcrumb + detail panel.
Key UI elements:
-
Search bar at top (smart search)
-
Breadcrumb navigation (click to go back to any level)
-
Main content area (renders different card layouts per navigation level)
-
Right slide-in detail panel (part detail with stock/bodegas/alternatives)
-
Cart sidebar (from current implementation, kept working)
-
Offline banner
-
Step 1: Rewrite catalog.html
<!DOCTYPE html>
<html lang="es" data-theme="industrial">
<head>
<script>/*pos_theme_early*/(function(){var t=localStorage.getItem("pos_theme")||"industrial";document.documentElement.setAttribute("data-theme",t);})()</script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Catalogo — Nexus Autoparts POS</title>
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<style>
/* =========================================================================
BASE RESET & SHELL
========================================================================= */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
font-family: var(--font-body);
font-size: var(--text-body);
color: var(--color-text-primary);
background-color: var(--color-bg-base);
transition: background-color var(--duration-normal) var(--ease-in-out),
color var(--duration-normal) var(--ease-in-out);
overflow: hidden;
}
[data-theme="modern"] body {
background-image: radial-gradient(circle, var(--dot-grid-color) 1px, transparent 1px);
background-size: var(--dot-grid-size) var(--dot-grid-size);
}
/* =========================================================================
APP LAYOUT
========================================================================= */
.app-shell { display: flex; height: 100vh; padding-top: 36px; }
/* =========================================================================
SIDEBAR (shared pattern)
========================================================================= */
.sidebar {
width: 260px; flex-shrink: 0; display: flex; flex-direction: column;
background: var(--color-bg-elevated); border-right: 1px solid var(--color-border);
overflow-y: auto; transition: var(--transition-normal);
}
.sidebar__brand {
display: flex; align-items: center; gap: var(--space-3);
padding: var(--space-5) var(--space-5) var(--space-4);
border-bottom: 1px solid var(--color-border); flex-shrink: 0;
}
.brand-logo {
width: 40px; height: 40px; display: flex; align-items: center; justify-content: center;
background: var(--color-primary); color: var(--color-text-inverse);
font-family: var(--font-heading); font-weight: var(--heading-weight-primary);
font-size: 1.375rem; letter-spacing: var(--tracking-tight); flex-shrink: 0;
}
[data-theme="industrial"] .brand-logo { clip-path: polygon(0 0, calc(100% - 10px) 0, 100% 10px, 100% 100%, 0 100%); border-radius: 0; }
[data-theme="modern"] .brand-logo { border-radius: var(--radius-md); }
.brand-name { display: flex; flex-direction: column; line-height: 1; }
.brand-name__primary { font-family: var(--font-heading); font-weight: var(--heading-weight-primary); font-size: 1.125rem; letter-spacing: var(--tracking-wide); color: var(--color-text-primary); text-transform: uppercase; }
.brand-name__sub { font-family: var(--font-body); font-size: var(--text-caption); color: var(--color-text-muted); letter-spacing: var(--tracking-wider); text-transform: uppercase; margin-top: 2px; }
.sidebar__nav { flex: 1; padding: var(--space-3) 0; }
.nav-section-label { padding: var(--space-3) var(--space-5) var(--space-1); font-size: var(--text-caption); font-family: var(--font-body); font-weight: var(--font-weight-semibold); color: var(--color-text-muted); letter-spacing: var(--tracking-widest); text-transform: uppercase; }
.nav-item { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) var(--space-5); color: var(--color-text-secondary); font-family: var(--font-body); font-size: var(--text-body-sm); font-weight: var(--font-weight-regular); text-decoration: none; cursor: pointer; border: none; background: none; width: 100%; text-align: left; transition: var(--transition-fast); border-left: 3px solid transparent; }
.nav-item:hover { background: var(--color-primary-muted); color: var(--color-text-primary); border-left-color: var(--color-primary); }
.nav-item.is-active { background: var(--color-primary-muted); color: var(--color-primary); font-weight: var(--font-weight-semibold); border-left-color: var(--color-primary); }
[data-theme="industrial"] .nav-item.is-active { background: rgba(245, 166, 35, 0.12); }
.nav-item__icon { width: 18px; height: 18px; opacity: 0.75; flex-shrink: 0; }
.nav-item.is-active .nav-item__icon, .nav-item:hover .nav-item__icon { opacity: 1; }
.nav-item__badge { margin-left: auto; background: var(--color-primary); color: var(--color-text-inverse); font-size: 10px; font-weight: var(--font-weight-bold); padding: 1px 6px; border-radius: var(--radius-full); line-height: 1.4; }
.sidebar__profile { padding: var(--space-4) var(--space-5); border-top: 1px solid var(--color-border); display: flex; align-items: center; gap: var(--space-3); flex-shrink: 0; }
.profile-avatar { width: 36px; height: 36px; background: var(--color-primary); color: var(--color-text-inverse); display: flex; align-items: center; justify-content: center; font-family: var(--font-heading); font-weight: var(--heading-weight-primary); font-size: 0.9rem; flex-shrink: 0; }
[data-theme="industrial"] .profile-avatar { clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 8px, 100% 100%, 0 100%); }
[data-theme="modern"] .profile-avatar { border-radius: var(--radius-full); }
.profile-info { flex: 1; min-width: 0; }
.profile-info__name { font-size: var(--text-body-sm); font-weight: var(--font-weight-semibold); color: var(--color-text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.profile-info__role { font-size: var(--text-caption); color: var(--color-text-muted); }
/* =========================================================================
MAIN CONTENT
========================================================================= */
.main-content { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
/* Header with breadcrumb + search */
.content-header {
display: flex; align-items: center; justify-content: space-between;
padding: 0 var(--space-6); height: 56px; flex-shrink: 0;
background: var(--color-bg-elevated); border-bottom: 1px solid var(--color-border);
}
.breadcrumb { display: flex; align-items: center; gap: var(--space-2); font-size: var(--text-body-sm); color: var(--color-text-muted); flex-wrap: wrap; }
.breadcrumb__link { color: var(--color-text-muted); text-decoration: none; cursor: pointer; transition: var(--transition-fast); }
.breadcrumb__link:hover { color: var(--color-primary); }
.breadcrumb__sep { color: var(--color-text-disabled); }
.breadcrumb__current { color: var(--color-text-primary); font-weight: var(--font-weight-semibold); }
.header-actions { display: flex; align-items: center; gap: var(--space-3); }
/* Search bar */
.search-bar {
display: flex; align-items: center; gap: var(--space-2);
background: var(--color-bg-overlay); border: 1px solid var(--color-border);
padding: var(--space-1) var(--space-3); width: 360px; transition: var(--transition-fast);
}
[data-theme="industrial"] .search-bar { border-radius: 0; }
[data-theme="modern"] .search-bar { border-radius: var(--radius-md); }
.search-bar:focus-within { border-color: var(--color-border-focus); box-shadow: var(--shadow-focus); }
.search-bar svg { color: var(--color-text-muted); flex-shrink: 0; }
.search-bar input {
flex: 1; border: none; background: transparent; color: var(--color-text-primary);
font-family: var(--font-body); font-size: var(--text-body-sm); outline: none;
height: 32px;
}
.search-bar input::placeholder { color: var(--color-text-disabled); }
/* Search dropdown */
.search-dropdown {
position: absolute; top: 100%; left: 0; right: 0;
background: var(--color-bg-elevated); border: 1px solid var(--color-border);
box-shadow: var(--shadow-lg); max-height: 400px; overflow-y: auto;
display: none; z-index: var(--z-dropdown);
}
[data-theme="industrial"] .search-dropdown { border-radius: 0; }
[data-theme="modern"] .search-dropdown { border-radius: var(--radius-md); }
.search-dropdown.is-visible { display: block; }
.search-result-item {
display: flex; align-items: center; gap: var(--space-3);
padding: var(--space-3) var(--space-4); cursor: pointer;
border-bottom: 1px solid var(--color-border); transition: var(--transition-fast);
}
.search-result-item:hover { background: var(--color-primary-muted); }
.search-result-item:last-child { border-bottom: none; }
.search-result__oem { font-weight: var(--font-weight-semibold); color: var(--color-primary); font-size: var(--text-body-sm); }
.search-result__name { color: var(--color-text-primary); font-size: var(--text-body-sm); }
.search-result__vehicle { font-size: var(--text-caption); color: var(--color-text-muted); }
/* =========================================================================
PAGE BODY
========================================================================= */
.page-body {
flex: 1; overflow-y: auto; padding: var(--space-6);
display: flex; flex-direction: column; gap: var(--space-5);
}
[data-theme="modern"] .page-body {
background-image: radial-gradient(circle, var(--dot-grid-color) 1px, transparent 1px);
background-size: var(--dot-grid-size) var(--dot-grid-size);
}
/* Level title */
.level-title {
font-family: var(--font-heading); font-weight: var(--heading-weight-primary);
font-size: var(--text-h4); color: var(--color-text-primary);
letter-spacing: var(--tracking-wide); text-transform: uppercase;
}
[data-theme="industrial"] .level-title { color: var(--color-primary); }
/* Filter input (quick filter within level) */
.level-filter {
padding: var(--space-2) var(--space-3);
background: var(--color-bg-overlay); border: 1px solid var(--color-border);
color: var(--color-text-primary); font-family: var(--font-body);
font-size: var(--text-body-sm); outline: none; width: 100%; max-width: 400px;
transition: var(--transition-fast);
}
[data-theme="industrial"] .level-filter { border-radius: 0; }
[data-theme="modern"] .level-filter { border-radius: var(--radius-md); }
.level-filter:focus { border-color: var(--color-border-focus); box-shadow: var(--shadow-focus); }
/* =========================================================================
NAVIGATION CARDS
========================================================================= */
.nav-grid {
display: grid; gap: var(--space-4);
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
.nav-grid--years {
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
}
.nav-grid--parts {
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
}
.nav-card {
display: flex; flex-direction: column; justify-content: center; align-items: center;
padding: var(--space-5) var(--space-4); gap: var(--space-2);
background: var(--color-bg-elevated); border: 1px solid var(--color-border);
cursor: pointer; transition: var(--transition-fast); text-align: center;
box-shadow: var(--shadow-sm); min-height: 80px;
}
[data-theme="industrial"] .nav-card { border-radius: 0; clip-path: polygon(0 0, calc(100% - 14px) 0, 100% 14px, 100% 100%, 0 100%); }
[data-theme="modern"] .nav-card { border-radius: var(--radius-lg); }
.nav-card:hover { border-color: var(--color-primary); box-shadow: var(--shadow-md); transform: translateY(-2px); }
.nav-card__name { font-family: var(--font-heading); font-weight: var(--heading-weight-secondary); font-size: var(--text-body); color: var(--color-text-primary); letter-spacing: var(--tracking-wide); }
.nav-card__sub { font-size: var(--text-caption); color: var(--color-text-muted); }
.nav-card__count { font-size: var(--text-caption); color: var(--color-primary); font-weight: var(--font-weight-semibold); }
/* Year buttons (compact) */
.nav-card--year { min-height: 48px; padding: var(--space-3); }
.nav-card--year .nav-card__name { font-size: var(--text-h5); }
/* =========================================================================
PART CARDS
========================================================================= */
.part-card {
display: flex; flex-direction: column;
background: var(--color-bg-elevated); border: 1px solid var(--color-border);
box-shadow: var(--shadow-sm); cursor: pointer; transition: var(--transition-fast);
overflow: hidden;
}
[data-theme="industrial"] .part-card { border-radius: 0; clip-path: polygon(0 0, calc(100% - 14px) 0, 100% 14px, 100% 100%, 0 100%); }
[data-theme="modern"] .part-card { border-radius: var(--radius-lg); }
.part-card:hover { border-color: var(--color-primary); box-shadow: var(--shadow-md); }
.part-card__image {
height: 120px; display: flex; align-items: center; justify-content: center;
background: var(--color-bg-overlay); position: relative; overflow: hidden;
color: var(--color-text-disabled);
}
.part-card__image img { width: 100%; height: 100%; object-fit: contain; }
.part-card__body { padding: var(--space-3) var(--space-4); flex: 1; }
.part-card__oem { font-family: var(--font-mono, monospace); font-size: var(--text-caption); color: var(--color-primary); font-weight: var(--font-weight-semibold); margin-bottom: var(--space-1); }
.part-card__name { font-size: var(--text-body-sm); font-weight: var(--font-weight-semibold); color: var(--color-text-primary); line-height: 1.3; }
.part-card__footer {
padding: var(--space-3) var(--space-4);
border-top: 1px solid var(--color-border);
display: flex; align-items: center; justify-content: space-between;
}
.part-card__price { font-weight: var(--font-weight-bold); color: var(--color-text-primary); }
/* Stock badges */
.stock-badge {
display: inline-flex; align-items: center; gap: 4px;
font-size: 11px; font-weight: var(--font-weight-bold);
padding: 2px 8px; border-radius: var(--radius-full);
}
.stock-badge--local { background: var(--color-success-light); color: var(--color-success-dark); }
.stock-badge--bodega { background: var(--color-warning-light); color: var(--color-warning-dark); }
.stock-badge--none { background: var(--color-neutral-200); color: var(--color-neutral-600); }
[data-theme="industrial"] .stock-badge--none { background: var(--color-neutral-700); color: var(--color-neutral-400); }
/* =========================================================================
DETAIL PANEL (slide-in from right)
========================================================================= */
.detail-overlay {
position: fixed; inset: 0; z-index: calc(var(--z-modal) - 2);
background: rgba(0,0,0,0.3); backdrop-filter: blur(2px); display: none;
}
.detail-overlay.is-visible { display: block; }
.detail-panel {
position: fixed; top: 0; right: 0; bottom: 0;
width: 440px; max-width: 100vw; z-index: calc(var(--z-modal) - 1);
background: var(--color-bg-elevated); border-left: 1px solid var(--color-border);
box-shadow: var(--shadow-xl); display: flex; flex-direction: column;
transform: translateX(100%); transition: transform var(--duration-normal) var(--ease-in-out);
}
.detail-panel.is-open { transform: translateX(0); }
.detail-header {
display: flex; align-items: center; justify-content: space-between;
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--color-border); flex-shrink: 0;
}
.detail-header h3 { font-family: var(--font-heading); font-size: var(--text-h5); font-weight: var(--heading-weight-secondary); color: var(--color-text-primary); }
.detail-close {
background: none; border: none; cursor: pointer; font-size: 1.4rem;
color: var(--color-text-secondary); padding: var(--space-1);
}
.detail-close:hover { color: var(--color-text-primary); }
.detail-body { flex: 1; overflow-y: auto; padding: var(--space-5); }
.detail-section { margin-bottom: var(--space-5); }
.detail-section__title {
font-family: var(--font-heading); font-size: var(--text-body-sm);
font-weight: var(--heading-weight-secondary); color: var(--color-text-muted);
text-transform: uppercase; letter-spacing: var(--tracking-wider);
margin-bottom: var(--space-3); padding-bottom: var(--space-2);
border-bottom: 1px solid var(--color-border);
}
.detail-oem { font-family: var(--font-mono, monospace); font-size: var(--text-h5); color: var(--color-primary); font-weight: var(--font-weight-bold); margin-bottom: var(--space-2); }
.detail-name { font-size: var(--text-body); font-weight: var(--font-weight-semibold); color: var(--color-text-primary); margin-bottom: var(--space-2); }
.detail-desc { font-size: var(--text-body-sm); color: var(--color-text-secondary); line-height: 1.5; }
/* Stock info */
.stock-row { display: flex; align-items: center; justify-content: space-between; padding: var(--space-2) 0; }
.stock-label { font-size: var(--text-body-sm); color: var(--color-text-secondary); }
.stock-value { font-weight: var(--font-weight-bold); font-size: var(--text-body-sm); }
.stock-value--ok { color: var(--color-success); }
.stock-value--zero { color: var(--color-text-muted); }
/* Bodega table */
.bodega-table { width: 100%; font-size: var(--text-body-sm); border-collapse: collapse; }
.bodega-table th { text-align: left; font-weight: var(--font-weight-semibold); color: var(--color-text-muted); font-size: var(--text-caption); text-transform: uppercase; letter-spacing: var(--tracking-wider); padding: var(--space-2) var(--space-2); border-bottom: 1px solid var(--color-border); }
.bodega-table td { padding: var(--space-2); border-bottom: 1px solid var(--color-border); color: var(--color-text-primary); }
/* Alternatives list */
.alt-item { display: flex; align-items: center; justify-content: space-between; padding: var(--space-2) 0; border-bottom: 1px solid var(--color-border); }
.alt-item:last-child { border-bottom: none; }
.alt-item__pn { font-weight: var(--font-weight-semibold); color: var(--color-text-primary); font-size: var(--text-body-sm); }
.alt-item__mfr { font-size: var(--text-caption); color: var(--color-text-muted); }
.alt-item__stock { font-size: var(--text-caption); }
/* Add to cart section */
.detail-footer {
padding: var(--space-4) var(--space-5); border-top: 1px solid var(--color-border);
flex-shrink: 0; background: var(--color-bg-elevated);
}
.qty-row { display: flex; align-items: center; gap: var(--space-3); margin-bottom: var(--space-3); }
.qty-btn {
width: 36px; height: 36px; display: flex; align-items: center; justify-content: center;
border: 1px solid var(--color-border); background: var(--color-bg-base);
color: var(--color-text-primary); cursor: pointer; font-size: 1.2rem;
transition: var(--transition-fast);
}
[data-theme="industrial"] .qty-btn { border-radius: 0; }
[data-theme="modern"] .qty-btn { border-radius: var(--radius-sm); }
.qty-btn:hover { border-color: var(--color-primary); color: var(--color-primary); }
.qty-display { font-weight: var(--font-weight-bold); font-size: var(--text-body); min-width: 30px; text-align: center; }
/* Buttons */
.btn { display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2); padding: 0 var(--space-5); height: 40px; font-family: var(--font-body); font-size: var(--text-body-sm); font-weight: var(--font-weight-semibold); border: 1px solid transparent; cursor: pointer; transition: var(--transition-fast); white-space: nowrap; letter-spacing: var(--tracking-wide); }
[data-theme="industrial"] .btn { border-radius: 0; clip-path: polygon(0 0, calc(100% - 10px) 0, 100% 10px, 100% 100%, 0 100%); text-transform: uppercase; }
[data-theme="modern"] .btn { border-radius: var(--radius-md); }
.btn-primary { background: var(--btn-primary-bg); color: var(--btn-primary-text); border-color: var(--btn-primary-border); }
.btn-primary:hover { background: var(--btn-primary-bg-hover); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-ghost { background: var(--btn-ghost-bg); color: var(--btn-ghost-text); border-color: var(--btn-ghost-border); }
.btn-ghost:hover { border-color: var(--color-primary); color: var(--color-primary); }
/* =========================================================================
PAGINATION
========================================================================= */
.pagination { display: flex; align-items: center; justify-content: center; gap: var(--space-2); padding: var(--space-4) 0; }
.page-item { min-width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; border: 1px solid var(--color-border); background: var(--color-bg-elevated); color: var(--color-text-secondary); font-size: var(--text-body-sm); font-weight: var(--font-weight-semibold); cursor: pointer; transition: var(--transition-fast); }
[data-theme="industrial"] .page-item { border-radius: 0; }
[data-theme="modern"] .page-item { border-radius: var(--radius-md); }
.page-item:hover { border-color: var(--color-primary); color: var(--color-primary); }
.page-item.is-active { background: var(--color-primary); border-color: var(--color-primary); color: var(--color-text-inverse); }
.page-item.is-disabled { opacity: 0.4; cursor: not-allowed; }
.page-item--wide { padding: 0 var(--space-4); gap: var(--space-2); }
/* =========================================================================
EMPTY STATE
========================================================================= */
.empty-state {
display: none; flex-direction: column; align-items: center; justify-content: center;
padding: var(--space-10) var(--space-6); text-align: center; gap: var(--space-3);
}
.empty-state.is-visible { display: flex; }
.empty-state__title { font-family: var(--font-heading); font-weight: var(--heading-weight-secondary); font-size: var(--text-h4); color: var(--color-text-primary); }
.empty-state__subtitle { font-size: var(--text-body-sm); color: var(--color-text-muted); max-width: 400px; }
/* =========================================================================
LOADING SPINNER
========================================================================= */
.loading { display: none; justify-content: center; padding: var(--space-10); }
.loading.is-visible { display: flex; }
.spinner { width: 40px; height: 40px; border: 3px solid var(--color-border); border-top-color: var(--color-primary); border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* =========================================================================
CART FAB + SIDEBAR
========================================================================= */
.cart-fab {
position: fixed; bottom: var(--space-6); right: var(--space-6);
width: 56px; height: 56px; z-index: var(--z-sticky);
display: flex; align-items: center; justify-content: center;
background: var(--color-primary); color: var(--color-text-inverse);
border: none; cursor: pointer; box-shadow: var(--shadow-lg);
transition: var(--transition-fast);
}
[data-theme="industrial"] .cart-fab { border-radius: 0; clip-path: polygon(0 0, calc(100% - 12px) 0, 100% 12px, 100% 100%, 0 100%); }
[data-theme="modern"] .cart-fab { border-radius: var(--radius-full); }
.cart-fab:hover { transform: scale(1.08); }
.cart-fab__badge {
position: absolute; top: -4px; right: -4px; min-width: 20px; height: 20px;
padding: 0 5px; border-radius: var(--radius-full);
background: var(--color-error); color: #fff; font-size: 11px;
font-weight: var(--font-weight-bold); display: none;
align-items: center; justify-content: center; line-height: 1;
}
.cart-sidebar {
position: fixed; top: 0; right: 0; bottom: 0; width: 360px; max-width: 100vw;
z-index: var(--z-modal); background: var(--color-bg-elevated);
border-left: 1px solid var(--color-border); box-shadow: var(--shadow-xl);
display: flex; flex-direction: column;
transform: translateX(100%); transition: transform var(--duration-normal) var(--ease-in-out);
}
.cart-sidebar.open { transform: translateX(0); }
.cart-header { display: flex; align-items: center; justify-content: space-between; padding: var(--space-4) var(--space-5); border-bottom: 1px solid var(--color-border); flex-shrink: 0; }
.cart-header h3 { font-family: var(--font-heading); font-size: var(--text-h5); font-weight: var(--heading-weight-secondary); color: var(--color-text-primary); }
.cart-items { flex: 1; overflow-y: auto; padding: var(--space-3) var(--space-4); }
.cart-item { display: flex; gap: var(--space-3); padding: var(--space-3) 0; border-bottom: 1px solid var(--color-border); }
.cart-item:last-child { border-bottom: none; }
.cart-footer { padding: var(--space-4) var(--space-5); border-top: 1px solid var(--color-border); flex-shrink: 0; background: var(--color-bg-elevated); }
.cart-totals { display: flex; flex-direction: column; gap: var(--space-1); font-size: var(--text-body-sm); color: var(--color-text-secondary); }
.cart-overlay { position: fixed; inset: 0; z-index: calc(var(--z-modal) - 1); background: rgba(0,0,0,0.3); backdrop-filter: blur(2px); display: none; }
.cart-overlay.open { display: block; }
/* =========================================================================
THEME BAR
========================================================================= */
.theme-bar { position: fixed; top: 0; left: 0; right: 0; z-index: var(--z-toast); display: flex; align-items: center; justify-content: flex-end; gap: var(--space-2); padding: var(--space-2) var(--space-4); background: var(--color-bg-overlay); border-bottom: 1px solid var(--color-border); backdrop-filter: blur(8px); height: 36px; }
.theme-bar__label { font-size: var(--text-caption); color: var(--color-text-muted); font-family: var(--font-body); letter-spacing: var(--tracking-wide); text-transform: uppercase; }
.theme-btn { display: inline-flex; align-items: center; gap: var(--space-1); padding: 3px var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-full); background: transparent; color: var(--color-text-secondary); font-family: var(--font-body); font-size: var(--text-caption); font-weight: var(--font-weight-semibold); cursor: pointer; transition: var(--transition-fast); }
.theme-btn:hover { border-color: var(--color-primary); color: var(--color-primary); }
.theme-btn.is-active { background: var(--color-primary); border-color: var(--color-primary); color: var(--color-text-inverse); }
/* =========================================================================
RESPONSIVE
========================================================================= */
@media (max-width: 768px) {
.sidebar { position: fixed; left: -260px; z-index: var(--z-modal); transition: left var(--duration-normal) var(--ease-in-out); height: 100vh; }
.sidebar.is-open { left: 0; }
.search-bar { width: 200px; }
.detail-panel { width: 100%; }
.nav-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
}
</style>
</head>
<body>
<!-- Theme switcher bar -->
<div class="theme-bar" role="toolbar" aria-label="Cambiar tema">
<span class="theme-bar__label">Tema:</span>
<button class="theme-btn is-active" data-theme-switch="industrial" aria-pressed="true">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12 2l2.4 1.6L17 3l1 2.6 2.4 1.6-.2 2.8L22 12l-1.8 2 .2 2.8-2.4 1.6-1 2.6-2.6-.2L12 22l-2.4-1.6L7 21l-1-2.6-2.4-1.6.2-2.8L2 12l1.8-2-.2-2.8L6 5.6 7 3l2.6.2z"/><circle cx="12" cy="12" r="3"/></svg>
Industrial
</button>
<button class="theme-btn" data-theme-switch="modern" aria-pressed="false">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>
Moderno
</button>
</div>
<!-- Mobile sidebar overlay -->
<div class="sidebar-overlay" id="sidebarOverlay" role="presentation"></div>
<!-- APP SHELL -->
<div class="app-shell">
<!-- SIDEBAR -->
<aside class="sidebar themed-scrollbar" id="sidebar" role="navigation" aria-label="Menu principal">
<div class="sidebar__brand">
<div class="brand-logo" aria-hidden="true">N</div>
<div class="brand-name">
<span class="brand-name__primary">Nexus</span>
<span class="brand-name__sub">Autoparts POS</span>
</div>
</div>
<nav class="sidebar__nav">
<div class="nav-section-label">Principal</div>
<a class="nav-item" href="/pos/dashboard" role="menuitem">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
Dashboard
</a>
<a class="nav-item" href="/pos/sale" role="menuitem">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"/><path d="M3 6h18"/><path d="M16 10a4 4 0 01-8 0"/></svg>
Punto de Venta
</a>
<a class="nav-item" href="/pos/inventory" role="menuitem">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"/></svg>
Inventario
</a>
<a class="nav-item is-active" href="/pos/catalog" role="menuitem" aria-current="page">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M8 7h8M8 12h8M8 17h5"/></svg>
Catalogo
</a>
<div class="nav-section-label" style="margin-top: var(--space-2);">Gestion</div>
<a class="nav-item" href="/pos/customers" role="menuitem">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>
Clientes
</a>
<a class="nav-item" href="/pos/invoicing" role="menuitem">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14,2 14,8 20,8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
Facturacion
</a>
<a class="nav-item" href="/pos/accounting" role="menuitem">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6"/></svg>
Contabilidad
</a>
<a class="nav-item" href="/pos/reports" role="menuitem">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><polyline points="22,12 18,12 15,21 9,3 6,12 2,12"/></svg>
Reportes
</a>
<div class="nav-section-label" style="margin-top: var(--space-2);">Sistema</div>
<a class="nav-item" href="/pos/config" role="menuitem">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M6.34 17.66l-1.41 1.41M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M12 2v2M12 20v2M2 12h2M20 12h2"/></svg>
Configuracion
</a>
</nav>
<div class="sidebar__profile">
<div class="profile-avatar" id="profileAvatar" aria-hidden="true">--</div>
<div class="profile-info">
<div class="profile-info__name" id="profileName">--</div>
<div class="profile-info__role" id="profileRole">--</div>
</div>
</div>
</aside>
<!-- MAIN CONTENT -->
<main class="main-content">
<!-- Header: breadcrumb + search -->
<header class="content-header">
<nav class="breadcrumb" id="breadcrumb" aria-label="Navegacion del catalogo">
<span class="breadcrumb__current">Catalogo</span>
</nav>
<div class="header-actions" style="position:relative;">
<div class="search-bar" id="searchBar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
<input type="text" id="searchInput" placeholder="Buscar por numero de parte o nombre... (F1)" autocomplete="off" />
</div>
<div class="search-dropdown" id="searchDropdown"></div>
</div>
</header>
<!-- Scrollable page body -->
<div class="page-body" id="pageBody">
<!-- Level title + optional filter -->
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-4); flex-wrap:wrap;">
<h2 class="level-title" id="levelTitle">Selecciona una marca</h2>
<input type="text" class="level-filter" id="levelFilter" placeholder="Filtrar..." style="display:none;" />
</div>
<!-- Loading spinner -->
<div class="loading" id="loading"><div class="spinner"></div></div>
<!-- Empty state -->
<div class="empty-state" id="emptyState">
<div class="empty-state__title" id="emptyTitle">Sin resultados</div>
<div class="empty-state__subtitle" id="emptySubtitle">No se encontraron datos para este nivel.</div>
</div>
<!-- Navigation grid (brands, models, years, engines, categories, groups) -->
<div class="nav-grid" id="navGrid" role="list"></div>
<!-- Parts grid (only for parts level) -->
<div class="nav-grid nav-grid--parts" id="partsGrid" role="list" style="display:none;"></div>
<!-- Pagination -->
<nav class="pagination" id="pagination" aria-label="Paginacion"></nav>
</div>
</main>
</div>
<!-- Detail panel overlay -->
<div class="detail-overlay" id="detailOverlay"></div>
<!-- Detail panel (slide-in) -->
<aside class="detail-panel" id="detailPanel">
<div class="detail-header">
<h3>Detalle de parte</h3>
<button class="detail-close" id="detailClose" aria-label="Cerrar detalle">✕</button>
</div>
<div class="detail-body" id="detailBody">
<!-- Populated by JS -->
</div>
<div class="detail-footer" id="detailFooter">
<div class="qty-row">
<button class="qty-btn" id="qtyMinus">-</button>
<span class="qty-display" id="qtyDisplay">1</span>
<button class="qty-btn" id="qtyPlus">+</button>
</div>
<button class="btn btn-primary" id="addToCartBtn" style="width:100%;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 002 1.61h9.72a2 2 0 002-1.61L23 6H6"/></svg>
Agregar al carrito
</button>
</div>
</aside>
<!-- Cart FAB -->
<button class="cart-fab" id="cartFab" aria-label="Abrir carrito">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 002 1.61h9.72a2 2 0 002-1.61L23 6H6"/></svg>
<span class="cart-fab__badge" id="cartBadge">0</span>
</button>
<!-- Cart overlay -->
<div class="cart-overlay" id="cartOverlay"></div>
<!-- Cart sidebar -->
<aside class="cart-sidebar" id="cartSidebar">
<div class="cart-header">
<h3>Carrito</h3>
<button id="cartCloseBtn" style="background:none;border:none;cursor:pointer;font-size:1.4rem;color:var(--color-text-secondary);padding:var(--space-1);">✕</button>
</div>
<div class="cart-items" id="cartItems"></div>
<div class="cart-empty" id="cartEmpty" style="display:none;padding:2rem;text-align:center;color:var(--color-text-muted);">Carrito vacio</div>
<div class="cart-footer">
<div class="cart-totals">
<div>Subtotal: <span id="cartSubtotal">$0.00</span></div>
<div>IVA 16%: <span id="cartTax">$0.00</span></div>
<div style="font-weight:bold;font-size:1.2em;">Total: <span id="cartTotal">$0.00</span></div>
</div>
<button id="checkoutBtn" class="btn btn-primary" style="width:100%;margin-top:var(--space-3);">Ir a cobrar →</button>
</div>
</aside>
<!-- Offline Banner -->
<div id="offlineBanner" class="banner banner--warning" style="display:none;position:fixed;top:0;left:0;right:0;z-index:9999;border-radius:0;">
<span class="banner__text" id="offlineBannerText"><strong>Modo offline</strong> — Mostrando solo tu inventario local.</span>
<button class="banner__dismiss" onclick="document.getElementById('offlineBanner').style.display='none'" aria-label="Cerrar">×</button>
</div>
<script src="/pos/static/js/app-init.js"></script>
<script src="/pos/static/js/sidebar.js"></script>
<script src="/pos/static/js/catalog.js"></script>
<script src="/pos/static/js/offline-banner.js"></script>
</body>
</html>
Task 4: Catalog JS rewrite
Files:
- Rewrite:
/home/Autopartes/pos/static/js/catalog.js
Complete rewrite. Implements:
-
Navigation state machine (levels: brands, models, years, engines, categories, groups, parts)
-
Breadcrumb management
-
Smart search with debounce
-
Part detail panel
-
Cart (localStorage, same format as before)
-
Offline fallback to local inventory
-
Barcode scanner support
-
F1 focus search
-
Step 1: Rewrite catalog.js
// /home/Autopartes/pos/static/js/catalog.js
// Catalog UI: TecDoc vehicle navigation with local stock enrichment, cart, detail panel.
(function () {
'use strict';
var API = '/pos/api/catalog';
var token = localStorage.getItem('pos_token');
if (!token) { window.location.href = '/pos/login'; return; }
var headers = { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
// ─── DOM refs ───
var breadcrumb = document.getElementById('breadcrumb');
var searchInput = document.getElementById('searchInput');
var searchDropdown = document.getElementById('searchDropdown');
var levelTitle = document.getElementById('levelTitle');
var levelFilter = document.getElementById('levelFilter');
var loading = document.getElementById('loading');
var emptyState = document.getElementById('emptyState');
var emptyTitle = document.getElementById('emptyTitle');
var emptySubtitle = document.getElementById('emptySubtitle');
var navGrid = document.getElementById('navGrid');
var partsGrid = document.getElementById('partsGrid');
var paginationNav = document.getElementById('pagination');
var pageBody = document.getElementById('pageBody');
// Detail panel
var detailPanel = document.getElementById('detailPanel');
var detailOverlay = document.getElementById('detailOverlay');
var detailBody = document.getElementById('detailBody');
var detailFooter = document.getElementById('detailFooter');
var detailClose = document.getElementById('detailClose');
var qtyMinus = document.getElementById('qtyMinus');
var qtyPlus = document.getElementById('qtyPlus');
var qtyDisplay = document.getElementById('qtyDisplay');
var addToCartBtn = document.getElementById('addToCartBtn');
// Cart
var cartSidebar = document.getElementById('cartSidebar');
var cartOverlay = document.getElementById('cartOverlay');
var cartItemsEl = document.getElementById('cartItems');
var cartEmptyEl = document.getElementById('cartEmpty');
var cartSubtotalEl= document.getElementById('cartSubtotal');
var cartTaxEl = document.getElementById('cartTax');
var cartTotalEl = document.getElementById('cartTotal');
var cartBadge = document.getElementById('cartBadge');
var checkoutBtn = document.getElementById('checkoutBtn');
var cartFab = document.getElementById('cartFab');
var cartCloseBtn = document.getElementById('cartCloseBtn');
// ─── Navigation State ───
var nav = {
level: 'brands', // brands|models|years|engines|categories|groups|parts
brand: null, // {id, name}
model: null, // {id, name}
year: null, // {id, year}
engine: null, // {id_mye, name}
category: null, // {id, name}
group: null, // {id, name}
};
var currentPage = 1;
var currentDetailPart = null;
var detailQty = 1;
var isOffline = false;
// ─── Cart State ───
var cartItems = JSON.parse(localStorage.getItem('pos_cart') || '[]');
// ─── API helper ───
function apiFetch(url) {
return fetch(url, { headers: headers })
.then(function (resp) {
if (resp.status === 401) {
localStorage.removeItem('pos_token');
window.location.href = '/pos/login';
return null;
}
return resp.json();
})
.catch(function (e) {
console.error('API error:', e);
return null;
});
}
// ─── UI helpers ───
function showLoading() { loading.classList.add('is-visible'); navGrid.innerHTML = ''; partsGrid.style.display = 'none'; partsGrid.innerHTML = ''; emptyState.classList.remove('is-visible'); paginationNav.innerHTML = ''; }
function hideLoading() { loading.classList.remove('is-visible'); }
function showEmpty(title, subtitle) {
emptyTitle.textContent = title;
emptySubtitle.textContent = subtitle || '';
emptyState.classList.add('is-visible');
navGrid.innerHTML = '';
partsGrid.style.display = 'none';
}
function fmt(n) { return (parseFloat(n) || 0).toFixed(2); }
function esc(s) {
if (!s) return '';
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// ─── Breadcrumb ───
function updateBreadcrumb() {
var parts = [];
parts.push({ label: 'Catalogo', action: 'loadBrands' });
if (nav.brand) parts.push({ label: nav.brand.name, action: 'loadModels' });
if (nav.model) parts.push({ label: nav.model.name, action: 'loadYears' });
if (nav.year) parts.push({ label: String(nav.year.year), action: 'loadEngines' });
if (nav.engine) parts.push({ label: nav.engine.name, action: 'loadCategories' });
if (nav.category) parts.push({ label: nav.category.name, action: 'loadGroups' });
if (nav.group) parts.push({ label: nav.group.name, action: null });
var html = '';
for (var i = 0; i < parts.length; i++) {
if (i > 0) html += '<span class="breadcrumb__sep" aria-hidden="true">/</span>';
if (i < parts.length - 1 && parts[i].action) {
html += '<a class="breadcrumb__link" data-bc-action="' + parts[i].action + '">' + esc(parts[i].label) + '</a>';
} else {
html += '<span class="breadcrumb__current">' + esc(parts[i].label) + '</span>';
}
}
breadcrumb.innerHTML = html;
// Wire breadcrumb clicks
breadcrumb.querySelectorAll('[data-bc-action]').forEach(function (el) {
el.addEventListener('click', function () {
var action = this.dataset.bcAction;
if (action === 'loadBrands') { resetNav(); loadBrands(); }
else if (action === 'loadModels') { resetNavFrom('models'); loadModels(); }
else if (action === 'loadYears') { resetNavFrom('years'); loadYears(); }
else if (action === 'loadEngines') { resetNavFrom('engines'); loadEngines(); }
else if (action === 'loadCategories') { resetNavFrom('categories'); loadCategories(); }
else if (action === 'loadGroups') { resetNavFrom('groups'); loadGroups(); }
});
});
}
function resetNav() {
nav.level = 'brands';
nav.brand = nav.model = nav.year = nav.engine = nav.category = nav.group = null;
}
function resetNavFrom(level) {
var levels = ['brands', 'models', 'years', 'engines', 'categories', 'groups', 'parts'];
var idx = levels.indexOf(level);
if (idx <= 0) { resetNav(); return; }
nav.level = level;
var keys = [null, 'model', 'year', 'engine', 'category', 'group', null];
for (var i = idx; i < keys.length; i++) {
if (keys[i]) nav[keys[i]] = null;
}
}
// ─── Level filter ───
function setupLevelFilter(show) {
if (!show) { levelFilter.style.display = 'none'; levelFilter.value = ''; return; }
levelFilter.style.display = '';
levelFilter.value = '';
levelFilter.oninput = function () {
var q = this.value.toLowerCase();
var cards = navGrid.querySelectorAll('.nav-card');
cards.forEach(function (card) {
var text = card.textContent.toLowerCase();
card.style.display = text.indexOf(q) >= 0 ? '' : 'none';
});
};
}
// ─── LEVEL LOADERS ───
function loadBrands() {
nav.level = 'brands';
updateBreadcrumb();
levelTitle.textContent = 'Selecciona una marca';
setupLevelFilter(true);
showLoading();
apiFetch(API + '/brands').then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) {
if (!data) {
enterOfflineMode();
return;
}
showEmpty('Sin marcas', 'El catalogo no tiene marcas con partes disponibles.');
return;
}
navGrid.className = 'nav-grid';
navGrid.innerHTML = data.data.map(function (b) {
return '<div class="nav-card" role="listitem" data-brand-id="' + b.id_brand + '" data-name="' + esc(b.name_brand) + '">' +
'<div class="nav-card__name">' + esc(b.name_brand) + '</div>' +
'</div>';
}).join('');
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
card.addEventListener('click', function () {
nav.brand = { id: parseInt(this.dataset.brandId), name: this.dataset.name };
loadModels();
});
});
});
}
function loadModels() {
nav.level = 'models';
updateBreadcrumb();
levelTitle.textContent = 'Modelos de ' + nav.brand.name;
setupLevelFilter(true);
showLoading();
apiFetch(API + '/models?brand_id=' + nav.brand.id).then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) { showEmpty('Sin modelos', 'No hay modelos con partes para ' + nav.brand.name); return; }
navGrid.className = 'nav-grid';
navGrid.innerHTML = data.data.map(function (m) {
return '<div class="nav-card" role="listitem" data-model-id="' + m.id_model + '" data-name="' + esc(m.name_model) + '">' +
'<div class="nav-card__name">' + esc(m.name_model) + '</div>' +
'</div>';
}).join('');
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
card.addEventListener('click', function () {
nav.model = { id: parseInt(this.dataset.modelId), name: this.dataset.name };
loadYears();
});
});
});
}
function loadYears() {
nav.level = 'years';
updateBreadcrumb();
levelTitle.textContent = nav.brand.name + ' ' + nav.model.name + ' — Anios';
setupLevelFilter(false);
showLoading();
apiFetch(API + '/years?model_id=' + nav.model.id).then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) { showEmpty('Sin anios', 'No hay anios con partes para este modelo.'); return; }
navGrid.className = 'nav-grid nav-grid--years';
navGrid.innerHTML = data.data.map(function (y) {
return '<div class="nav-card nav-card--year" role="listitem" data-year-id="' + y.id_year + '" data-year="' + y.year_car + '">' +
'<div class="nav-card__name">' + y.year_car + '</div>' +
'</div>';
}).join('');
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
card.addEventListener('click', function () {
nav.year = { id: parseInt(this.dataset.yearId), year: parseInt(this.dataset.year) };
loadEngines();
});
});
});
}
function loadEngines() {
nav.level = 'engines';
updateBreadcrumb();
levelTitle.textContent = nav.brand.name + ' ' + nav.model.name + ' ' + nav.year.year + ' — Motor';
setupLevelFilter(false);
showLoading();
apiFetch(API + '/engines?model_id=' + nav.model.id + '&year_id=' + nav.year.id).then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) { showEmpty('Sin motores', 'No hay configuraciones de motor para esta combinacion.'); return; }
// If only one engine, auto-select it
if (data.data.length === 1) {
var e = data.data[0];
nav.engine = { id_mye: e.id_mye, name: e.name_engine + (e.trim_level ? ' ' + e.trim_level : '') };
loadCategories();
return;
}
navGrid.className = 'nav-grid';
navGrid.innerHTML = data.data.map(function (e) {
var label = e.name_engine + (e.trim_level ? ' — ' + e.trim_level : '');
return '<div class="nav-card" role="listitem" data-mye-id="' + e.id_mye + '" data-name="' + esc(label) + '">' +
'<div class="nav-card__name">' + esc(e.name_engine) + '</div>' +
(e.trim_level ? '<div class="nav-card__sub">' + esc(e.trim_level) + '</div>' : '') +
'</div>';
}).join('');
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
card.addEventListener('click', function () {
nav.engine = { id_mye: parseInt(this.dataset.myeId), name: this.dataset.name };
loadCategories();
});
});
});
}
function loadCategories() {
nav.level = 'categories';
updateBreadcrumb();
levelTitle.textContent = 'Categorias de partes';
setupLevelFilter(true);
showLoading();
apiFetch(API + '/categories?mye_id=' + nav.engine.id_mye).then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) { showEmpty('Sin categorias', 'No hay partes catalogadas para este vehiculo.'); return; }
navGrid.className = 'nav-grid';
navGrid.innerHTML = data.data.map(function (c) {
return '<div class="nav-card" role="listitem" data-cat-id="' + c.id_part_category + '" data-name="' + esc(c.name) + '">' +
'<div class="nav-card__name">' + esc(c.name) + '</div>' +
'<div class="nav-card__count">' + c.part_count + ' partes</div>' +
'</div>';
}).join('');
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
card.addEventListener('click', function () {
nav.category = { id: parseInt(this.dataset.catId), name: this.dataset.name };
loadGroups();
});
});
});
}
function loadGroups() {
nav.level = 'groups';
updateBreadcrumb();
levelTitle.textContent = nav.category.name;
setupLevelFilter(true);
showLoading();
apiFetch(API + '/groups?mye_id=' + nav.engine.id_mye + '&category_id=' + nav.category.id).then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) { showEmpty('Sin subcategorias', 'No hay subcategorias para ' + nav.category.name); return; }
navGrid.className = 'nav-grid';
navGrid.innerHTML = data.data.map(function (g) {
return '<div class="nav-card" role="listitem" data-group-id="' + g.id_part_group + '" data-name="' + esc(g.name) + '">' +
'<div class="nav-card__name">' + esc(g.name) + '</div>' +
'<div class="nav-card__count">' + g.part_count + ' partes</div>' +
'</div>';
}).join('');
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
card.addEventListener('click', function () {
nav.group = { id: parseInt(this.dataset.groupId), name: this.dataset.name };
loadParts(1);
});
});
});
}
function loadParts(page) {
nav.level = 'parts';
currentPage = page || 1;
updateBreadcrumb();
levelTitle.textContent = nav.group.name;
setupLevelFilter(false);
showLoading();
navGrid.innerHTML = '';
apiFetch(API + '/parts?mye_id=' + nav.engine.id_mye + '&group_id=' + nav.group.id + '&page=' + currentPage + '&per_page=30').then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) { showEmpty('Sin partes', 'No hay partes en este grupo.'); return; }
partsGrid.style.display = '';
partsGrid.innerHTML = data.data.map(function (p) {
var stockBadge;
if (p.local_stock > 0) {
stockBadge = '<span class="stock-badge stock-badge--local">En stock: ' + p.local_stock + '</span>';
} else if (p.bodega_count > 0) {
stockBadge = '<span class="stock-badge stock-badge--bodega">' + p.bodega_count + ' bodega' + (p.bodega_count > 1 ? 's' : '') + '</span>';
} else {
stockBadge = '<span class="stock-badge stock-badge--none">Sin stock</span>';
}
var imgHtml = p.image_url
? '<img src="' + esc(p.image_url) + '" alt="' + esc(p.name) + '">'
: '<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M8 7h8M8 12h8M8 17h5"/></svg>';
return '<article class="part-card" role="listitem" data-part-id="' + p.id_part + '">' +
'<div class="part-card__image">' + imgHtml + '</div>' +
'<div class="part-card__body">' +
'<div class="part-card__oem">' + esc(p.oem_part_number) + '</div>' +
'<div class="part-card__name">' + esc(p.name) + '</div>' +
'</div>' +
'<div class="part-card__footer">' +
(p.local_price ? '<span class="part-card__price">$' + fmt(p.local_price) + '</span>' : '<span class="part-card__price" style="color:var(--color-text-muted);">Sin precio</span>') +
stockBadge +
'</div>' +
'</article>';
}).join('');
// Wire part card clicks → open detail panel
partsGrid.querySelectorAll('.part-card').forEach(function (card) {
card.addEventListener('click', function () {
openPartDetail(parseInt(this.dataset.partId));
});
});
// Pagination
if (data.pagination) renderPagination(data.pagination);
});
}
// ─── PAGINATION ───
function renderPagination(pg) {
if (!pg || pg.total_pages <= 1) { paginationNav.innerHTML = ''; return; }
var html = '';
if (pg.page <= 1) {
html += '<button class="page-item page-item--wide is-disabled" disabled>Anterior</button>';
} else {
html += '<button class="page-item page-item--wide" data-page="' + (pg.page - 1) + '">Anterior</button>';
}
var pages = buildPageNumbers(pg.page, pg.total_pages);
pages.forEach(function (p) {
if (p === '...') {
html += '<span style="padding:0 4px;color:var(--color-text-muted);">...</span>';
} else if (p === pg.page) {
html += '<button class="page-item is-active">' + p + '</button>';
} else {
html += '<button class="page-item" data-page="' + p + '">' + p + '</button>';
}
});
if (pg.page >= pg.total_pages) {
html += '<button class="page-item page-item--wide is-disabled" disabled>Siguiente</button>';
} else {
html += '<button class="page-item page-item--wide" data-page="' + (pg.page + 1) + '">Siguiente</button>';
}
paginationNav.innerHTML = html;
paginationNav.querySelectorAll('[data-page]').forEach(function (btn) {
btn.addEventListener('click', function () {
pageBody.scrollTo({ top: 0, behavior: 'smooth' });
loadParts(parseInt(this.dataset.page));
});
});
}
function buildPageNumbers(current, total) {
if (total <= 7) { var a = []; for (var i = 1; i <= total; i++) a.push(i); return a; }
var p = [1];
if (current > 3) p.push('...');
for (var j = Math.max(2, current - 1); j <= Math.min(total - 1, current + 1); j++) p.push(j);
if (current < total - 2) p.push('...');
p.push(total);
return p;
}
// ─── DETAIL PANEL ───
function openPartDetail(partId) {
detailBody.innerHTML = '<div class="loading is-visible"><div class="spinner"></div></div>';
detailFooter.style.display = 'none';
detailPanel.classList.add('is-open');
detailOverlay.classList.add('is-visible');
detailQty = 1;
qtyDisplay.textContent = '1';
apiFetch(API + '/part/' + partId).then(function (data) {
if (!data || data.error) {
detailBody.innerHTML = '<p style="color:var(--color-error);padding:var(--space-4);">Error al cargar detalle.</p>';
return;
}
currentDetailPart = data;
var p = data.part;
var local = data.local;
var bodegas = data.bodegas || [];
var alts = data.alternatives || [];
var html = '';
// Part info
html += '<div class="detail-section">';
if (p.category_name) html += '<div style="font-size:var(--text-caption);color:var(--color-text-muted);margin-bottom:var(--space-1);">' + esc(p.category_name) + ' > ' + esc(p.group_name) + '</div>';
html += '<div class="detail-oem">' + esc(p.oem_part_number) + '</div>';
html += '<div class="detail-name">' + esc(p.name) + '</div>';
if (p.description) html += '<div class="detail-desc">' + esc(p.description) + '</div>';
if (p.image_url) html += '<div style="margin-top:var(--space-3);text-align:center;"><img src="' + esc(p.image_url) + '" alt="" style="max-width:100%;max-height:200px;object-fit:contain;border-radius:var(--radius-sm);"></div>';
html += '</div>';
// Local stock
html += '<div class="detail-section">';
html += '<div class="detail-section__title">Mi stock</div>';
if (local && local.stock > 0) {
html += '<div class="stock-row"><span class="stock-label">Cantidad</span><span class="stock-value stock-value--ok">' + local.stock + ' ' + (local.unit || 'PZA') + '</span></div>';
html += '<div class="stock-row"><span class="stock-label">Precio publico</span><span class="stock-value">$' + fmt(local.price_1) + '</span></div>';
if (local.price_2) html += '<div class="stock-row"><span class="stock-label">Precio mayoreo</span><span class="stock-value">$' + fmt(local.price_2) + '</span></div>';
if (local.price_3) html += '<div class="stock-row"><span class="stock-label">Precio taller</span><span class="stock-value">$' + fmt(local.price_3) + '</span></div>';
if (local.location) html += '<div class="stock-row"><span class="stock-label">Ubicacion</span><span class="stock-value">' + esc(local.location) + '</span></div>';
} else {
html += '<div style="color:var(--color-text-muted);font-size:var(--text-body-sm);">No tienes esta parte en inventario.</div>';
}
html += '</div>';
// Bodegas
if (bodegas.length) {
html += '<div class="detail-section">';
html += '<div class="detail-section__title">Disponible en bodegas</div>';
html += '<table class="bodega-table"><thead><tr><th>Bodega</th><th>Precio</th><th>Stock</th></tr></thead><tbody>';
bodegas.forEach(function (b) {
html += '<tr><td>' + esc(b.business_name) + '</td><td>' + (b.price ? '$' + fmt(b.price) : '--') + '</td><td>' + b.stock + '</td></tr>';
});
html += '</tbody></table></div>';
}
// Alternatives
if (alts.length) {
html += '<div class="detail-section">';
html += '<div class="detail-section__title">Alternativas / Cross-references</div>';
alts.forEach(function (a) {
var stockLabel = a.local_stock > 0
? '<span class="stock-badge stock-badge--local">Stock: ' + a.local_stock + '</span>'
: (a.bodega_count > 0 ? '<span class="stock-badge stock-badge--bodega">' + a.bodega_count + ' bod.</span>' : '');
html += '<div class="alt-item">' +
'<div><div class="alt-item__pn">' + esc(a.part_number) + '</div>' +
'<div class="alt-item__mfr">' + esc(a.manufacturer) + (a.name ? ' — ' + esc(a.name) : '') + '</div></div>' +
'<div class="alt-item__stock">' + stockLabel + '</div>' +
'</div>';
});
html += '</div>';
}
detailBody.innerHTML = html;
// Show footer only if we have local stock
if (local && local.stock > 0) {
detailFooter.style.display = '';
} else {
detailFooter.style.display = 'none';
}
});
}
function closeDetail() {
detailPanel.classList.remove('is-open');
detailOverlay.classList.remove('is-visible');
currentDetailPart = null;
}
detailClose.addEventListener('click', closeDetail);
detailOverlay.addEventListener('click', closeDetail);
qtyMinus.addEventListener('click', function () { if (detailQty > 1) { detailQty--; qtyDisplay.textContent = detailQty; } });
qtyPlus.addEventListener('click', function () { detailQty++; qtyDisplay.textContent = detailQty; });
addToCartBtn.addEventListener('click', function () {
if (!currentDetailPart) return;
var p = currentDetailPart.part;
var local = currentDetailPart.local;
if (!local) return;
addToCart({
id: p.id_part,
part_number: p.oem_part_number,
name: p.name,
brand: '',
price: local.price_1,
tax_rate: local.tax_rate || 0.16,
unit: local.unit || 'PZA',
stock: local.stock,
source: 'local',
inventory_id: local.inventory_id,
}, detailQty);
closeDetail();
});
// ─── SMART SEARCH ───
var searchTimeout = null;
searchInput.addEventListener('input', function () {
clearTimeout(searchTimeout);
var q = this.value.trim();
if (q.length < 2) { searchDropdown.classList.remove('is-visible'); return; }
searchTimeout = setTimeout(function () { runSearch(q); }, 350);
});
searchInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
clearTimeout(searchTimeout);
var q = this.value.trim();
if (q.length >= 2) runSearch(q);
}
if (e.key === 'Escape') {
searchDropdown.classList.remove('is-visible');
}
});
// Close dropdown on outside click
document.addEventListener('click', function (e) {
if (!searchDropdown.contains(e.target) && e.target !== searchInput) {
searchDropdown.classList.remove('is-visible');
}
});
function runSearch(q) {
apiFetch(API + '/search?q=' + encodeURIComponent(q) + '&limit=20').then(function (data) {
if (!data || !data.data || !data.data.length) {
searchDropdown.innerHTML = '<div style="padding:var(--space-4);color:var(--color-text-muted);font-size:var(--text-body-sm);">Sin resultados para "' + esc(q) + '"</div>';
searchDropdown.classList.add('is-visible');
return;
}
searchDropdown.innerHTML = data.data.map(function (r) {
var stockLabel = r.local_stock > 0
? '<span class="stock-badge stock-badge--local" style="margin-left:auto;">Stock: ' + r.local_stock + '</span>'
: '';
return '<div class="search-result-item" data-part-id="' + r.id_part + '">' +
'<div style="flex:1;">' +
'<div class="search-result__oem">' + esc(r.oem_part_number) + '</div>' +
'<div class="search-result__name">' + esc(r.name) + '</div>' +
(r.vehicle_info ? '<div class="search-result__vehicle">' + esc(r.vehicle_info) + '</div>' : '') +
'</div>' +
stockLabel +
'</div>';
}).join('');
searchDropdown.classList.add('is-visible');
searchDropdown.querySelectorAll('.search-result-item').forEach(function (el) {
el.addEventListener('click', function () {
searchDropdown.classList.remove('is-visible');
openPartDetail(parseInt(this.dataset.partId));
});
});
});
}
// ─── CART ───
function addToCart(item, qty) {
qty = qty || 1;
var existing = cartItems.find(function (c) { return c.id === item.id; });
if (existing) {
existing.quantity += qty;
} else {
cartItems.push({
id: item.id,
part_number: item.part_number,
name: item.name,
brand: item.brand || '',
price: item.price,
tax_rate: item.tax_rate || 0.16,
unit: item.unit || 'PZA',
stock: item.stock,
source: item.source || 'local',
inventory_id: item.inventory_id,
quantity: qty,
});
}
saveCart();
renderCart();
if (!cartSidebar.classList.contains('open')) toggleCart();
}
function removeFromCart(index) {
cartItems.splice(index, 1);
saveCart();
renderCart();
}
function updateQuantity(index, qty) {
qty = parseInt(qty);
if (qty <= 0) { removeFromCart(index); return; }
cartItems[index].quantity = qty;
saveCart();
renderCart();
}
function clearCart() {
cartItems = [];
saveCart();
renderCart();
}
function saveCart() { localStorage.setItem('pos_cart', JSON.stringify(cartItems)); }
function renderCart() {
var total = cartItems.reduce(function (s, c) { return s + c.quantity; }, 0);
if (cartBadge) {
cartBadge.textContent = total;
cartBadge.style.display = total > 0 ? 'flex' : 'none';
}
if (!cartItems.length) {
cartItemsEl.innerHTML = '';
cartEmptyEl.style.display = 'block';
if (checkoutBtn) checkoutBtn.disabled = true;
cartSubtotalEl.textContent = '$0.00';
cartTaxEl.textContent = '$0.00';
cartTotalEl.textContent = '$0.00';
return;
}
cartEmptyEl.style.display = 'none';
if (checkoutBtn) checkoutBtn.disabled = false;
var subtotal = 0;
var tax = 0;
cartItemsEl.innerHTML = cartItems.map(function (c, i) {
var lineTotal = c.price * c.quantity;
var lineTax = lineTotal * c.tax_rate;
subtotal += lineTotal;
tax += lineTax;
return '<div class="cart-item">' +
'<div style="flex:1;">' +
'<div style="font-weight:600;font-size:0.85rem;color:var(--color-text-primary);">' + esc(c.name) + '</div>' +
'<div style="font-size:0.75rem;color:var(--color-text-muted);">' + esc(c.part_number) + '</div>' +
'<div style="margin-top:4px;display:flex;align-items:center;gap:6px;">' +
'<button data-cart-action="dec" data-idx="' + i + '" style="width:24px;height:24px;border:1px solid var(--color-border);background:var(--color-bg-base);border-radius:var(--radius-sm);cursor:pointer;color:var(--color-text-primary);">-</button>' +
'<span style="font-weight:600;color:var(--color-text-primary);">' + c.quantity + '</span>' +
'<button data-cart-action="inc" data-idx="' + i + '" style="width:24px;height:24px;border:1px solid var(--color-border);background:var(--color-bg-base);border-radius:var(--radius-sm);cursor:pointer;color:var(--color-text-primary);">+</button>' +
'</div></div>' +
'<div style="text-align:right;">' +
'<div style="font-weight:600;color:var(--color-text-primary);">$' + fmt(lineTotal) + '</div>' +
'<button data-cart-action="remove" data-idx="' + i + '" style="font-size:0.75rem;color:var(--color-error,#ef4444);background:none;border:none;cursor:pointer;margin-top:4px;">Quitar</button>' +
'</div></div>';
}).join('');
cartSubtotalEl.textContent = '$' + fmt(subtotal);
cartTaxEl.textContent = '$' + fmt(tax);
cartTotalEl.textContent = '$' + fmt(subtotal + tax);
// Wire cart buttons
cartItemsEl.querySelectorAll('[data-cart-action]').forEach(function (btn) {
btn.addEventListener('click', function () {
var idx = parseInt(this.dataset.idx);
var action = this.dataset.cartAction;
if (action === 'dec') updateQuantity(idx, cartItems[idx].quantity - 1);
else if (action === 'inc') updateQuantity(idx, cartItems[idx].quantity + 1);
else if (action === 'remove') removeFromCart(idx);
});
});
}
function toggleCart() {
var isOpen = cartSidebar.classList.toggle('open');
cartOverlay.classList.toggle('open', isOpen);
}
function goToCheckout() {
if (!cartItems.length) return;
localStorage.setItem('pos_cart', JSON.stringify(cartItems));
window.location.href = '/pos/sale';
}
cartFab.addEventListener('click', toggleCart);
cartCloseBtn.addEventListener('click', toggleCart);
cartOverlay.addEventListener('click', toggleCart);
checkoutBtn.addEventListener('click', goToCheckout);
// ─── OFFLINE FALLBACK ───
function enterOfflineMode() {
isOffline = true;
document.getElementById('offlineBanner').style.display = '';
document.getElementById('offlineBannerText').innerHTML = '<strong>Modo offline</strong> — Mostrando solo tu inventario local.';
levelTitle.textContent = 'Inventario local';
setupLevelFilter(false);
// TODO: load local inventory via legacy /pos/api/catalog/search endpoint
showEmpty('Sin conexion al catalogo', 'Verifica tu conexion. El catalogo TecDoc requiere acceso al servidor central.');
}
// ─── BARCODE SCANNER ───
var barcodeBuffer = '';
var barcodeTimeout = null;
document.addEventListener('keydown', function (e) {
// F1 → focus search
if (e.key === 'F1') { e.preventDefault(); searchInput.focus(); return; }
// Escape → close panels
if (e.key === 'Escape') {
closeDetail();
if (cartSidebar.classList.contains('open')) toggleCart();
return;
}
// Barcode scanner detection
if (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA') return;
if (e.key === 'Enter' && barcodeBuffer.length >= 4) {
var code = barcodeBuffer.trim();
barcodeBuffer = '';
// Search for the barcode
searchInput.value = code;
runSearch(code);
return;
}
if (e.key.length === 1) {
barcodeBuffer += e.key;
clearTimeout(barcodeTimeout);
barcodeTimeout = setTimeout(function () { barcodeBuffer = ''; }, 200);
}
});
// ─── THEME SWITCHER ───
document.querySelectorAll('[data-theme-switch]').forEach(function (btn) {
btn.addEventListener('click', function () {
var theme = this.dataset.themeSwitch;
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('pos_theme', theme);
document.querySelectorAll('[data-theme-switch]').forEach(function (b) {
b.classList.remove('is-active');
b.setAttribute('aria-pressed', 'false');
});
this.classList.add('is-active');
this.setAttribute('aria-pressed', 'true');
});
// Set initial active state
var current = localStorage.getItem('pos_theme') || 'industrial';
if (btn.dataset.themeSwitch === current) {
btn.classList.add('is-active');
btn.setAttribute('aria-pressed', 'true');
} else {
btn.classList.remove('is-active');
btn.setAttribute('aria-pressed', 'false');
}
});
// ─── EXPOSE GLOBALS (for backward compat) ───
window.CatalogApp = {
toggleCart: toggleCart,
goToCheckout: goToCheckout,
addToCart: addToCart,
removeFromCart: removeFromCart,
updateQty: updateQuantity,
clearCart: clearCart,
loadPage: function (p) { loadParts(p); },
};
// ─── INIT ───
renderCart();
loadBrands();
})();
Task 5: Integration test
Files:
- Create:
/home/Autopartes/pos/tests/test_catalog_vehicle.py
Full navigation test: brands > Toyota > models > Corolla > years > engines > categories > parts > detail > search.
- Step 1: Create integration test
# /home/Autopartes/pos/tests/test_catalog_vehicle.py
"""Integration test for the catalog vehicle navigation.
Tests the full navigation flow:
brands → select one → models → select one → years → engines →
categories → groups → parts → part detail → search
Run: cd /home/Autopartes/pos && python -m pytest tests/test_catalog_vehicle.py -v
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import pytest
import json
from app import create_app
@pytest.fixture(scope='module')
def app():
"""Create test app."""
app = create_app()
app.config['TESTING'] = True
return app
@pytest.fixture(scope='module')
def client(app):
return app.test_client()
@pytest.fixture(scope='module')
def auth_headers(client):
"""Get a valid JWT token for testing.
Uses the test tenant credentials — adjust as needed.
"""
# Try PIN login (adjust tenant_id and PIN to match test data)
resp = client.post('/pos/api/auth/pin-login', json={
'tenant_id': 1,
'pin': '1234',
})
if resp.status_code != 200:
pytest.skip('Cannot authenticate — check test tenant/PIN setup')
data = resp.get_json()
token = data.get('access_token')
if not token:
pytest.skip('No access_token in login response')
return {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}
class TestCatalogNavigation:
"""Test the full vehicle navigation hierarchy."""
def test_01_brands(self, client, auth_headers):
"""GET /brands returns a list of vehicle brands."""
resp = client.get('/pos/api/catalog/brands', headers=auth_headers)
assert resp.status_code == 200
data = resp.get_json()
assert 'data' in data
assert isinstance(data['data'], list)
# Store for next tests
self.__class__.brands = data['data']
if data['data']:
print(f" Got {len(data['data'])} brands, first: {data['data'][0]['name_brand']}")
def test_02_models(self, client, auth_headers):
"""GET /models returns models for a brand."""
brands = getattr(self.__class__, 'brands', [])
if not brands:
pytest.skip('No brands available')
brand = brands[0]
resp = client.get(f'/pos/api/catalog/models?brand_id={brand["id_brand"]}', headers=auth_headers)
assert resp.status_code == 200
data = resp.get_json()
assert 'data' in data
self.__class__.models = data['data']
self.__class__.brand_id = brand['id_brand']
if data['data']:
print(f" Got {len(data['data'])} models for {brand['name_brand']}")
def test_03_years(self, client, auth_headers):
"""GET /years returns years for a model."""
models = getattr(self.__class__, 'models', [])
if not models:
pytest.skip('No models available')
model = models[0]
resp = client.get(f'/pos/api/catalog/years?model_id={model["id_model"]}', headers=auth_headers)
assert resp.status_code == 200
data = resp.get_json()
assert 'data' in data
self.__class__.years = data['data']
self.__class__.model_id = model['id_model']
if data['data']:
# Years should be DESC
years_list = [y['year_car'] for y in data['data']]
assert years_list == sorted(years_list, reverse=True), "Years should be DESC"
print(f" Got {len(data['data'])} years, range: {years_list[-1]}-{years_list[0]}")
def test_04_engines(self, client, auth_headers):
"""GET /engines returns engine configs for model+year."""
years = getattr(self.__class__, 'years', [])
model_id = getattr(self.__class__, 'model_id', None)
if not years or not model_id:
pytest.skip('No years available')
year = years[0]
resp = client.get(f'/pos/api/catalog/engines?model_id={model_id}&year_id={year["id_year"]}', headers=auth_headers)
assert resp.status_code == 200
data = resp.get_json()
assert 'data' in data
self.__class__.engines = data['data']
self.__class__.year_id = year['id_year']
if data['data']:
print(f" Got {len(data['data'])} engine configs")
def test_05_categories(self, client, auth_headers):
"""GET /categories returns part categories for a vehicle."""
engines = getattr(self.__class__, 'engines', [])
if not engines:
pytest.skip('No engines available')
engine = engines[0]
mye_id = engine['id_mye']
resp = client.get(f'/pos/api/catalog/categories?mye_id={mye_id}', headers=auth_headers)
assert resp.status_code == 200
data = resp.get_json()
assert 'data' in data
self.__class__.categories = data['data']
self.__class__.mye_id = mye_id
if data['data']:
print(f" Got {len(data['data'])} categories")
# Each should have a part_count
for cat in data['data']:
assert 'part_count' in cat
assert cat['part_count'] > 0
def test_06_groups(self, client, auth_headers):
"""GET /groups returns subcategories for a vehicle+category."""
categories = getattr(self.__class__, 'categories', [])
mye_id = getattr(self.__class__, 'mye_id', None)
if not categories or not mye_id:
pytest.skip('No categories available')
cat = categories[0]
resp = client.get(f'/pos/api/catalog/groups?mye_id={mye_id}&category_id={cat["id_part_category"]}', headers=auth_headers)
assert resp.status_code == 200
data = resp.get_json()
assert 'data' in data
self.__class__.groups = data['data']
if data['data']:
print(f" Got {len(data['data'])} groups in {cat['name']}")
def test_07_parts(self, client, auth_headers):
"""GET /parts returns parts with stock enrichment."""
groups = getattr(self.__class__, 'groups', [])
mye_id = getattr(self.__class__, 'mye_id', None)
if not groups or not mye_id:
pytest.skip('No groups available')
grp = groups[0]
resp = client.get(f'/pos/api/catalog/parts?mye_id={mye_id}&group_id={grp["id_part_group"]}&page=1&per_page=10', headers=auth_headers)
assert resp.status_code == 200
data = resp.get_json()
assert 'data' in data
assert 'pagination' in data
if data['data']:
part = data['data'][0]
# Verify enrichment fields exist
assert 'local_stock' in part
assert 'bodega_count' in part
assert 'oem_part_number' in part
self.__class__.part_id = part['id_part']
print(f" Got {len(data['data'])} parts, first: {part['oem_part_number']}")
def test_08_part_detail(self, client, auth_headers):
"""GET /part/<id> returns full detail with bodegas + alternatives."""
part_id = getattr(self.__class__, 'part_id', None)
if not part_id:
pytest.skip('No part_id available')
resp = client.get(f'/pos/api/catalog/part/{part_id}', headers=auth_headers)
assert resp.status_code == 200
data = resp.get_json()
assert 'part' in data
assert 'local' in data # can be null
assert 'bodegas' in data
assert 'alternatives' in data
assert data['part']['id_part'] == part_id
print(f" Detail for {data['part']['oem_part_number']}: "
f"bodegas={len(data['bodegas'])}, alts={len(data['alternatives'])}")
def test_09_search_by_text(self, client, auth_headers):
"""GET /search?q=brake returns results."""
resp = client.get('/pos/api/catalog/search?q=brake&limit=5', headers=auth_headers)
assert resp.status_code == 200
data = resp.get_json()
assert 'data' in data
# May be empty if no parts match, but should not error
print(f" Search 'brake': {len(data['data'])} results")
def test_10_search_by_part_number(self, client, auth_headers):
"""GET /search?q=<oem> finds by part number."""
part_id = getattr(self.__class__, 'part_id', None)
if not part_id:
pytest.skip('No part_id available')
# Get the OEM number first
resp = client.get(f'/pos/api/catalog/part/{part_id}', headers=auth_headers)
oem = resp.get_json()['part']['oem_part_number']
# Search by it
resp2 = client.get(f'/pos/api/catalog/search?q={oem}&limit=5', headers=auth_headers)
assert resp2.status_code == 200
data = resp2.get_json()
assert len(data['data']) > 0, f"Search by OEM '{oem}' should find at least 1 result"
found_oems = [r['oem_part_number'] for r in data['data']]
assert oem in found_oems
print(f" Search by OEM '{oem}': found")
def test_11_missing_params(self, client, auth_headers):
"""Endpoints return 400 when required params are missing."""
assert client.get('/pos/api/catalog/models', headers=auth_headers).status_code == 400
assert client.get('/pos/api/catalog/years', headers=auth_headers).status_code == 400
assert client.get('/pos/api/catalog/engines?model_id=1', headers=auth_headers).status_code == 400
assert client.get('/pos/api/catalog/categories', headers=auth_headers).status_code == 400
assert client.get('/pos/api/catalog/groups?mye_id=1', headers=auth_headers).status_code == 400
assert client.get('/pos/api/catalog/parts?mye_id=1', headers=auth_headers).status_code == 400
def test_12_part_not_found(self, client, auth_headers):
"""GET /part/999999999 returns 404."""
resp = client.get('/pos/api/catalog/part/999999999', headers=auth_headers)
assert resp.status_code == 404
def test_13_search_short_query(self, client, auth_headers):
"""GET /search?q=a returns empty (too short)."""
resp = client.get('/pos/api/catalog/search?q=a', headers=auth_headers)
assert resp.status_code == 200
data = resp.get_json()
assert data['data'] == []
Implementation Order
- Task 1 (catalog_service.py) — no dependencies, pure functions
- Task 2 (catalog_bp.py) — depends on Task 1
- Task 3 (catalog.html) — can be done in parallel with Task 2
- Task 4 (catalog.js) — depends on Task 3 for DOM IDs
- Task 5 (test) — depends on Tasks 1-2
Parallel execution opportunity: Tasks 1+3 can be done simultaneously (service + HTML template). Then Tasks 2+4 (blueprint + JS). Then Task 5.
Key Performance Notes
-
vehicle_parts has 14B+ rows. Every query MUST filter by
model_year_engine_id(indexed:idx_vehicle_parts_mye). Never doSELECT * FROM vehicle_partswithout a WHERE on this column. -
get_brands/get_models/get_years/get_engines all use
EXISTSsubqueries that stop at the first match, avoiding full scans. -
get_categories/get_groups do
GROUP BYon the filtered subset (single mye_id), which is bounded by the number of parts per vehicle (typically < 5000). -
get_parts paginates with LIMIT/OFFSET and does bulk stock lookups (single query for all OEMs in the page).
-
smart_search uses the
search_vectorGIN index for full-text search, falling back to ILIKE only as a secondary condition. -
Local stock enrichment is batched:
_get_local_stock_bulkfetches stock for all OEM numbers in one query usingANY(%s).