Files
Autoparts-DB/docs/plans/2026-04-01-catalog-vehiculo-plan.md
consultoria-as 4e08e12826 docs: add catalog vehicle navigation implementation plan
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>
2026-04-01 21:37:13 +00:00

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">&#10005;</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);">&#10005;</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 &rarr;</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">&times;</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

  1. Task 1 (catalog_service.py) — no dependencies, pure functions
  2. Task 2 (catalog_bp.py) — depends on Task 1
  3. Task 3 (catalog.html) — can be done in parallel with Task 2
  4. Task 4 (catalog.js) — depends on Task 3 for DOM IDs
  5. 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

  1. vehicle_parts has 14B+ rows. Every query MUST filter by model_year_engine_id (indexed: idx_vehicle_parts_mye). Never do SELECT * FROM vehicle_parts without a WHERE on this column.

  2. get_brands/get_models/get_years/get_engines all use EXISTS subqueries that stop at the first match, avoiding full scans.

  3. get_categories/get_groups do GROUP BY on the filtered subset (single mye_id), which is bounded by the number of parts per vehicle (typically < 5000).

  4. get_parts paginates with LIMIT/OFFSET and does bulk stock lookups (single query for all OEMs in the page).

  5. smart_search uses the search_vector GIN index for full-text search, falling back to ILIKE only as a secondary condition.

  6. Local stock enrichment is batched: _get_local_stock_bulk fetches stock for all OEM numbers in one query using ANY(%s).