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>
2678 lines
121 KiB
Markdown
2678 lines
121 KiB
Markdown
# 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**
|
|
|
|
```python
|
|
# /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**
|
|
|
|
```python
|
|
# /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**
|
|
|
|
```html
|
|
<!DOCTYPE html>
|
|
<html lang="es" data-theme="industrial">
|
|
<head>
|
|
<script>/*pos_theme_early*/(function(){var t=localStorage.getItem("pos_theme")||"industrial";document.documentElement.setAttribute("data-theme",t);})()</script>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>Catalogo — Nexus Autoparts POS</title>
|
|
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
|
|
|
<style>
|
|
/* =========================================================================
|
|
BASE RESET & SHELL
|
|
========================================================================= */
|
|
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
html, body { height: 100%; }
|
|
|
|
body {
|
|
font-family: var(--font-body);
|
|
font-size: var(--text-body);
|
|
color: var(--color-text-primary);
|
|
background-color: var(--color-bg-base);
|
|
transition: background-color var(--duration-normal) var(--ease-in-out),
|
|
color var(--duration-normal) var(--ease-in-out);
|
|
overflow: hidden;
|
|
}
|
|
|
|
[data-theme="modern"] body {
|
|
background-image: radial-gradient(circle, var(--dot-grid-color) 1px, transparent 1px);
|
|
background-size: var(--dot-grid-size) var(--dot-grid-size);
|
|
}
|
|
|
|
/* =========================================================================
|
|
APP LAYOUT
|
|
========================================================================= */
|
|
|
|
.app-shell { display: flex; height: 100vh; padding-top: 36px; }
|
|
|
|
/* =========================================================================
|
|
SIDEBAR (shared pattern)
|
|
========================================================================= */
|
|
|
|
.sidebar {
|
|
width: 260px; flex-shrink: 0; display: flex; flex-direction: column;
|
|
background: var(--color-bg-elevated); border-right: 1px solid var(--color-border);
|
|
overflow-y: auto; transition: var(--transition-normal);
|
|
}
|
|
.sidebar__brand {
|
|
display: flex; align-items: center; gap: var(--space-3);
|
|
padding: var(--space-5) var(--space-5) var(--space-4);
|
|
border-bottom: 1px solid var(--color-border); flex-shrink: 0;
|
|
}
|
|
.brand-logo {
|
|
width: 40px; height: 40px; display: flex; align-items: center; justify-content: center;
|
|
background: var(--color-primary); color: var(--color-text-inverse);
|
|
font-family: var(--font-heading); font-weight: var(--heading-weight-primary);
|
|
font-size: 1.375rem; letter-spacing: var(--tracking-tight); flex-shrink: 0;
|
|
}
|
|
[data-theme="industrial"] .brand-logo { clip-path: polygon(0 0, calc(100% - 10px) 0, 100% 10px, 100% 100%, 0 100%); border-radius: 0; }
|
|
[data-theme="modern"] .brand-logo { border-radius: var(--radius-md); }
|
|
.brand-name { display: flex; flex-direction: column; line-height: 1; }
|
|
.brand-name__primary { font-family: var(--font-heading); font-weight: var(--heading-weight-primary); font-size: 1.125rem; letter-spacing: var(--tracking-wide); color: var(--color-text-primary); text-transform: uppercase; }
|
|
.brand-name__sub { font-family: var(--font-body); font-size: var(--text-caption); color: var(--color-text-muted); letter-spacing: var(--tracking-wider); text-transform: uppercase; margin-top: 2px; }
|
|
|
|
.sidebar__nav { flex: 1; padding: var(--space-3) 0; }
|
|
.nav-section-label { padding: var(--space-3) var(--space-5) var(--space-1); font-size: var(--text-caption); font-family: var(--font-body); font-weight: var(--font-weight-semibold); color: var(--color-text-muted); letter-spacing: var(--tracking-widest); text-transform: uppercase; }
|
|
.nav-item { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) var(--space-5); color: var(--color-text-secondary); font-family: var(--font-body); font-size: var(--text-body-sm); font-weight: var(--font-weight-regular); text-decoration: none; cursor: pointer; border: none; background: none; width: 100%; text-align: left; transition: var(--transition-fast); border-left: 3px solid transparent; }
|
|
.nav-item:hover { background: var(--color-primary-muted); color: var(--color-text-primary); border-left-color: var(--color-primary); }
|
|
.nav-item.is-active { background: var(--color-primary-muted); color: var(--color-primary); font-weight: var(--font-weight-semibold); border-left-color: var(--color-primary); }
|
|
[data-theme="industrial"] .nav-item.is-active { background: rgba(245, 166, 35, 0.12); }
|
|
.nav-item__icon { width: 18px; height: 18px; opacity: 0.75; flex-shrink: 0; }
|
|
.nav-item.is-active .nav-item__icon, .nav-item:hover .nav-item__icon { opacity: 1; }
|
|
.nav-item__badge { margin-left: auto; background: var(--color-primary); color: var(--color-text-inverse); font-size: 10px; font-weight: var(--font-weight-bold); padding: 1px 6px; border-radius: var(--radius-full); line-height: 1.4; }
|
|
|
|
.sidebar__profile { padding: var(--space-4) var(--space-5); border-top: 1px solid var(--color-border); display: flex; align-items: center; gap: var(--space-3); flex-shrink: 0; }
|
|
.profile-avatar { width: 36px; height: 36px; background: var(--color-primary); color: var(--color-text-inverse); display: flex; align-items: center; justify-content: center; font-family: var(--font-heading); font-weight: var(--heading-weight-primary); font-size: 0.9rem; flex-shrink: 0; }
|
|
[data-theme="industrial"] .profile-avatar { clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 8px, 100% 100%, 0 100%); }
|
|
[data-theme="modern"] .profile-avatar { border-radius: var(--radius-full); }
|
|
.profile-info { flex: 1; min-width: 0; }
|
|
.profile-info__name { font-size: var(--text-body-sm); font-weight: var(--font-weight-semibold); color: var(--color-text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.profile-info__role { font-size: var(--text-caption); color: var(--color-text-muted); }
|
|
|
|
/* =========================================================================
|
|
MAIN CONTENT
|
|
========================================================================= */
|
|
|
|
.main-content { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
|
|
|
|
/* Header with breadcrumb + search */
|
|
.content-header {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding: 0 var(--space-6); height: 56px; flex-shrink: 0;
|
|
background: var(--color-bg-elevated); border-bottom: 1px solid var(--color-border);
|
|
}
|
|
|
|
.breadcrumb { display: flex; align-items: center; gap: var(--space-2); font-size: var(--text-body-sm); color: var(--color-text-muted); flex-wrap: wrap; }
|
|
.breadcrumb__link { color: var(--color-text-muted); text-decoration: none; cursor: pointer; transition: var(--transition-fast); }
|
|
.breadcrumb__link:hover { color: var(--color-primary); }
|
|
.breadcrumb__sep { color: var(--color-text-disabled); }
|
|
.breadcrumb__current { color: var(--color-text-primary); font-weight: var(--font-weight-semibold); }
|
|
|
|
.header-actions { display: flex; align-items: center; gap: var(--space-3); }
|
|
|
|
/* Search bar */
|
|
.search-bar {
|
|
display: flex; align-items: center; gap: var(--space-2);
|
|
background: var(--color-bg-overlay); border: 1px solid var(--color-border);
|
|
padding: var(--space-1) var(--space-3); width: 360px; transition: var(--transition-fast);
|
|
}
|
|
[data-theme="industrial"] .search-bar { border-radius: 0; }
|
|
[data-theme="modern"] .search-bar { border-radius: var(--radius-md); }
|
|
.search-bar:focus-within { border-color: var(--color-border-focus); box-shadow: var(--shadow-focus); }
|
|
.search-bar svg { color: var(--color-text-muted); flex-shrink: 0; }
|
|
.search-bar input {
|
|
flex: 1; border: none; background: transparent; color: var(--color-text-primary);
|
|
font-family: var(--font-body); font-size: var(--text-body-sm); outline: none;
|
|
height: 32px;
|
|
}
|
|
.search-bar input::placeholder { color: var(--color-text-disabled); }
|
|
|
|
/* Search dropdown */
|
|
.search-dropdown {
|
|
position: absolute; top: 100%; left: 0; right: 0;
|
|
background: var(--color-bg-elevated); border: 1px solid var(--color-border);
|
|
box-shadow: var(--shadow-lg); max-height: 400px; overflow-y: auto;
|
|
display: none; z-index: var(--z-dropdown);
|
|
}
|
|
[data-theme="industrial"] .search-dropdown { border-radius: 0; }
|
|
[data-theme="modern"] .search-dropdown { border-radius: var(--radius-md); }
|
|
.search-dropdown.is-visible { display: block; }
|
|
.search-result-item {
|
|
display: flex; align-items: center; gap: var(--space-3);
|
|
padding: var(--space-3) var(--space-4); cursor: pointer;
|
|
border-bottom: 1px solid var(--color-border); transition: var(--transition-fast);
|
|
}
|
|
.search-result-item:hover { background: var(--color-primary-muted); }
|
|
.search-result-item:last-child { border-bottom: none; }
|
|
.search-result__oem { font-weight: var(--font-weight-semibold); color: var(--color-primary); font-size: var(--text-body-sm); }
|
|
.search-result__name { color: var(--color-text-primary); font-size: var(--text-body-sm); }
|
|
.search-result__vehicle { font-size: var(--text-caption); color: var(--color-text-muted); }
|
|
|
|
/* =========================================================================
|
|
PAGE BODY
|
|
========================================================================= */
|
|
|
|
.page-body {
|
|
flex: 1; overflow-y: auto; padding: var(--space-6);
|
|
display: flex; flex-direction: column; gap: var(--space-5);
|
|
}
|
|
[data-theme="modern"] .page-body {
|
|
background-image: radial-gradient(circle, var(--dot-grid-color) 1px, transparent 1px);
|
|
background-size: var(--dot-grid-size) var(--dot-grid-size);
|
|
}
|
|
|
|
/* Level title */
|
|
.level-title {
|
|
font-family: var(--font-heading); font-weight: var(--heading-weight-primary);
|
|
font-size: var(--text-h4); color: var(--color-text-primary);
|
|
letter-spacing: var(--tracking-wide); text-transform: uppercase;
|
|
}
|
|
[data-theme="industrial"] .level-title { color: var(--color-primary); }
|
|
|
|
/* Filter input (quick filter within level) */
|
|
.level-filter {
|
|
padding: var(--space-2) var(--space-3);
|
|
background: var(--color-bg-overlay); border: 1px solid var(--color-border);
|
|
color: var(--color-text-primary); font-family: var(--font-body);
|
|
font-size: var(--text-body-sm); outline: none; width: 100%; max-width: 400px;
|
|
transition: var(--transition-fast);
|
|
}
|
|
[data-theme="industrial"] .level-filter { border-radius: 0; }
|
|
[data-theme="modern"] .level-filter { border-radius: var(--radius-md); }
|
|
.level-filter:focus { border-color: var(--color-border-focus); box-shadow: var(--shadow-focus); }
|
|
|
|
/* =========================================================================
|
|
NAVIGATION CARDS
|
|
========================================================================= */
|
|
|
|
.nav-grid {
|
|
display: grid; gap: var(--space-4);
|
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
}
|
|
.nav-grid--years {
|
|
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
|
}
|
|
.nav-grid--parts {
|
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
|
}
|
|
|
|
.nav-card {
|
|
display: flex; flex-direction: column; justify-content: center; align-items: center;
|
|
padding: var(--space-5) var(--space-4); gap: var(--space-2);
|
|
background: var(--color-bg-elevated); border: 1px solid var(--color-border);
|
|
cursor: pointer; transition: var(--transition-fast); text-align: center;
|
|
box-shadow: var(--shadow-sm); min-height: 80px;
|
|
}
|
|
[data-theme="industrial"] .nav-card { border-radius: 0; clip-path: polygon(0 0, calc(100% - 14px) 0, 100% 14px, 100% 100%, 0 100%); }
|
|
[data-theme="modern"] .nav-card { border-radius: var(--radius-lg); }
|
|
.nav-card:hover { border-color: var(--color-primary); box-shadow: var(--shadow-md); transform: translateY(-2px); }
|
|
.nav-card__name { font-family: var(--font-heading); font-weight: var(--heading-weight-secondary); font-size: var(--text-body); color: var(--color-text-primary); letter-spacing: var(--tracking-wide); }
|
|
.nav-card__sub { font-size: var(--text-caption); color: var(--color-text-muted); }
|
|
.nav-card__count { font-size: var(--text-caption); color: var(--color-primary); font-weight: var(--font-weight-semibold); }
|
|
|
|
/* Year buttons (compact) */
|
|
.nav-card--year { min-height: 48px; padding: var(--space-3); }
|
|
.nav-card--year .nav-card__name { font-size: var(--text-h5); }
|
|
|
|
/* =========================================================================
|
|
PART CARDS
|
|
========================================================================= */
|
|
|
|
.part-card {
|
|
display: flex; flex-direction: column;
|
|
background: var(--color-bg-elevated); border: 1px solid var(--color-border);
|
|
box-shadow: var(--shadow-sm); cursor: pointer; transition: var(--transition-fast);
|
|
overflow: hidden;
|
|
}
|
|
[data-theme="industrial"] .part-card { border-radius: 0; clip-path: polygon(0 0, calc(100% - 14px) 0, 100% 14px, 100% 100%, 0 100%); }
|
|
[data-theme="modern"] .part-card { border-radius: var(--radius-lg); }
|
|
.part-card:hover { border-color: var(--color-primary); box-shadow: var(--shadow-md); }
|
|
|
|
.part-card__image {
|
|
height: 120px; display: flex; align-items: center; justify-content: center;
|
|
background: var(--color-bg-overlay); position: relative; overflow: hidden;
|
|
color: var(--color-text-disabled);
|
|
}
|
|
.part-card__image img { width: 100%; height: 100%; object-fit: contain; }
|
|
|
|
.part-card__body { padding: var(--space-3) var(--space-4); flex: 1; }
|
|
.part-card__oem { font-family: var(--font-mono, monospace); font-size: var(--text-caption); color: var(--color-primary); font-weight: var(--font-weight-semibold); margin-bottom: var(--space-1); }
|
|
.part-card__name { font-size: var(--text-body-sm); font-weight: var(--font-weight-semibold); color: var(--color-text-primary); line-height: 1.3; }
|
|
|
|
.part-card__footer {
|
|
padding: var(--space-3) var(--space-4);
|
|
border-top: 1px solid var(--color-border);
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
}
|
|
.part-card__price { font-weight: var(--font-weight-bold); color: var(--color-text-primary); }
|
|
|
|
/* Stock badges */
|
|
.stock-badge {
|
|
display: inline-flex; align-items: center; gap: 4px;
|
|
font-size: 11px; font-weight: var(--font-weight-bold);
|
|
padding: 2px 8px; border-radius: var(--radius-full);
|
|
}
|
|
.stock-badge--local { background: var(--color-success-light); color: var(--color-success-dark); }
|
|
.stock-badge--bodega { background: var(--color-warning-light); color: var(--color-warning-dark); }
|
|
.stock-badge--none { background: var(--color-neutral-200); color: var(--color-neutral-600); }
|
|
[data-theme="industrial"] .stock-badge--none { background: var(--color-neutral-700); color: var(--color-neutral-400); }
|
|
|
|
/* =========================================================================
|
|
DETAIL PANEL (slide-in from right)
|
|
========================================================================= */
|
|
|
|
.detail-overlay {
|
|
position: fixed; inset: 0; z-index: calc(var(--z-modal) - 2);
|
|
background: rgba(0,0,0,0.3); backdrop-filter: blur(2px); display: none;
|
|
}
|
|
.detail-overlay.is-visible { display: block; }
|
|
|
|
.detail-panel {
|
|
position: fixed; top: 0; right: 0; bottom: 0;
|
|
width: 440px; max-width: 100vw; z-index: calc(var(--z-modal) - 1);
|
|
background: var(--color-bg-elevated); border-left: 1px solid var(--color-border);
|
|
box-shadow: var(--shadow-xl); display: flex; flex-direction: column;
|
|
transform: translateX(100%); transition: transform var(--duration-normal) var(--ease-in-out);
|
|
}
|
|
.detail-panel.is-open { transform: translateX(0); }
|
|
|
|
.detail-header {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding: var(--space-4) var(--space-5);
|
|
border-bottom: 1px solid var(--color-border); flex-shrink: 0;
|
|
}
|
|
.detail-header h3 { font-family: var(--font-heading); font-size: var(--text-h5); font-weight: var(--heading-weight-secondary); color: var(--color-text-primary); }
|
|
.detail-close {
|
|
background: none; border: none; cursor: pointer; font-size: 1.4rem;
|
|
color: var(--color-text-secondary); padding: var(--space-1);
|
|
}
|
|
.detail-close:hover { color: var(--color-text-primary); }
|
|
|
|
.detail-body { flex: 1; overflow-y: auto; padding: var(--space-5); }
|
|
|
|
.detail-section { margin-bottom: var(--space-5); }
|
|
.detail-section__title {
|
|
font-family: var(--font-heading); font-size: var(--text-body-sm);
|
|
font-weight: var(--heading-weight-secondary); color: var(--color-text-muted);
|
|
text-transform: uppercase; letter-spacing: var(--tracking-wider);
|
|
margin-bottom: var(--space-3); padding-bottom: var(--space-2);
|
|
border-bottom: 1px solid var(--color-border);
|
|
}
|
|
|
|
.detail-oem { font-family: var(--font-mono, monospace); font-size: var(--text-h5); color: var(--color-primary); font-weight: var(--font-weight-bold); margin-bottom: var(--space-2); }
|
|
.detail-name { font-size: var(--text-body); font-weight: var(--font-weight-semibold); color: var(--color-text-primary); margin-bottom: var(--space-2); }
|
|
.detail-desc { font-size: var(--text-body-sm); color: var(--color-text-secondary); line-height: 1.5; }
|
|
|
|
/* Stock info */
|
|
.stock-row { display: flex; align-items: center; justify-content: space-between; padding: var(--space-2) 0; }
|
|
.stock-label { font-size: var(--text-body-sm); color: var(--color-text-secondary); }
|
|
.stock-value { font-weight: var(--font-weight-bold); font-size: var(--text-body-sm); }
|
|
.stock-value--ok { color: var(--color-success); }
|
|
.stock-value--zero { color: var(--color-text-muted); }
|
|
|
|
/* Bodega table */
|
|
.bodega-table { width: 100%; font-size: var(--text-body-sm); border-collapse: collapse; }
|
|
.bodega-table th { text-align: left; font-weight: var(--font-weight-semibold); color: var(--color-text-muted); font-size: var(--text-caption); text-transform: uppercase; letter-spacing: var(--tracking-wider); padding: var(--space-2) var(--space-2); border-bottom: 1px solid var(--color-border); }
|
|
.bodega-table td { padding: var(--space-2); border-bottom: 1px solid var(--color-border); color: var(--color-text-primary); }
|
|
|
|
/* Alternatives list */
|
|
.alt-item { display: flex; align-items: center; justify-content: space-between; padding: var(--space-2) 0; border-bottom: 1px solid var(--color-border); }
|
|
.alt-item:last-child { border-bottom: none; }
|
|
.alt-item__pn { font-weight: var(--font-weight-semibold); color: var(--color-text-primary); font-size: var(--text-body-sm); }
|
|
.alt-item__mfr { font-size: var(--text-caption); color: var(--color-text-muted); }
|
|
.alt-item__stock { font-size: var(--text-caption); }
|
|
|
|
/* Add to cart section */
|
|
.detail-footer {
|
|
padding: var(--space-4) var(--space-5); border-top: 1px solid var(--color-border);
|
|
flex-shrink: 0; background: var(--color-bg-elevated);
|
|
}
|
|
.qty-row { display: flex; align-items: center; gap: var(--space-3); margin-bottom: var(--space-3); }
|
|
.qty-btn {
|
|
width: 36px; height: 36px; display: flex; align-items: center; justify-content: center;
|
|
border: 1px solid var(--color-border); background: var(--color-bg-base);
|
|
color: var(--color-text-primary); cursor: pointer; font-size: 1.2rem;
|
|
transition: var(--transition-fast);
|
|
}
|
|
[data-theme="industrial"] .qty-btn { border-radius: 0; }
|
|
[data-theme="modern"] .qty-btn { border-radius: var(--radius-sm); }
|
|
.qty-btn:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
|
.qty-display { font-weight: var(--font-weight-bold); font-size: var(--text-body); min-width: 30px; text-align: center; }
|
|
|
|
/* Buttons */
|
|
.btn { display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2); padding: 0 var(--space-5); height: 40px; font-family: var(--font-body); font-size: var(--text-body-sm); font-weight: var(--font-weight-semibold); border: 1px solid transparent; cursor: pointer; transition: var(--transition-fast); white-space: nowrap; letter-spacing: var(--tracking-wide); }
|
|
[data-theme="industrial"] .btn { border-radius: 0; clip-path: polygon(0 0, calc(100% - 10px) 0, 100% 10px, 100% 100%, 0 100%); text-transform: uppercase; }
|
|
[data-theme="modern"] .btn { border-radius: var(--radius-md); }
|
|
.btn-primary { background: var(--btn-primary-bg); color: var(--btn-primary-text); border-color: var(--btn-primary-border); }
|
|
.btn-primary:hover { background: var(--btn-primary-bg-hover); }
|
|
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
.btn-ghost { background: var(--btn-ghost-bg); color: var(--btn-ghost-text); border-color: var(--btn-ghost-border); }
|
|
.btn-ghost:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
|
|
|
/* =========================================================================
|
|
PAGINATION
|
|
========================================================================= */
|
|
|
|
.pagination { display: flex; align-items: center; justify-content: center; gap: var(--space-2); padding: var(--space-4) 0; }
|
|
.page-item { min-width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; border: 1px solid var(--color-border); background: var(--color-bg-elevated); color: var(--color-text-secondary); font-size: var(--text-body-sm); font-weight: var(--font-weight-semibold); cursor: pointer; transition: var(--transition-fast); }
|
|
[data-theme="industrial"] .page-item { border-radius: 0; }
|
|
[data-theme="modern"] .page-item { border-radius: var(--radius-md); }
|
|
.page-item:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
|
.page-item.is-active { background: var(--color-primary); border-color: var(--color-primary); color: var(--color-text-inverse); }
|
|
.page-item.is-disabled { opacity: 0.4; cursor: not-allowed; }
|
|
.page-item--wide { padding: 0 var(--space-4); gap: var(--space-2); }
|
|
|
|
/* =========================================================================
|
|
EMPTY STATE
|
|
========================================================================= */
|
|
|
|
.empty-state {
|
|
display: none; flex-direction: column; align-items: center; justify-content: center;
|
|
padding: var(--space-10) var(--space-6); text-align: center; gap: var(--space-3);
|
|
}
|
|
.empty-state.is-visible { display: flex; }
|
|
.empty-state__title { font-family: var(--font-heading); font-weight: var(--heading-weight-secondary); font-size: var(--text-h4); color: var(--color-text-primary); }
|
|
.empty-state__subtitle { font-size: var(--text-body-sm); color: var(--color-text-muted); max-width: 400px; }
|
|
|
|
/* =========================================================================
|
|
LOADING SPINNER
|
|
========================================================================= */
|
|
|
|
.loading { display: none; justify-content: center; padding: var(--space-10); }
|
|
.loading.is-visible { display: flex; }
|
|
.spinner { width: 40px; height: 40px; border: 3px solid var(--color-border); border-top-color: var(--color-primary); border-radius: 50%; animation: spin 0.8s linear infinite; }
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
|
|
/* =========================================================================
|
|
CART FAB + SIDEBAR
|
|
========================================================================= */
|
|
|
|
.cart-fab {
|
|
position: fixed; bottom: var(--space-6); right: var(--space-6);
|
|
width: 56px; height: 56px; z-index: var(--z-sticky);
|
|
display: flex; align-items: center; justify-content: center;
|
|
background: var(--color-primary); color: var(--color-text-inverse);
|
|
border: none; cursor: pointer; box-shadow: var(--shadow-lg);
|
|
transition: var(--transition-fast);
|
|
}
|
|
[data-theme="industrial"] .cart-fab { border-radius: 0; clip-path: polygon(0 0, calc(100% - 12px) 0, 100% 12px, 100% 100%, 0 100%); }
|
|
[data-theme="modern"] .cart-fab { border-radius: var(--radius-full); }
|
|
.cart-fab:hover { transform: scale(1.08); }
|
|
.cart-fab__badge {
|
|
position: absolute; top: -4px; right: -4px; min-width: 20px; height: 20px;
|
|
padding: 0 5px; border-radius: var(--radius-full);
|
|
background: var(--color-error); color: #fff; font-size: 11px;
|
|
font-weight: var(--font-weight-bold); display: none;
|
|
align-items: center; justify-content: center; line-height: 1;
|
|
}
|
|
|
|
.cart-sidebar {
|
|
position: fixed; top: 0; right: 0; bottom: 0; width: 360px; max-width: 100vw;
|
|
z-index: var(--z-modal); background: var(--color-bg-elevated);
|
|
border-left: 1px solid var(--color-border); box-shadow: var(--shadow-xl);
|
|
display: flex; flex-direction: column;
|
|
transform: translateX(100%); transition: transform var(--duration-normal) var(--ease-in-out);
|
|
}
|
|
.cart-sidebar.open { transform: translateX(0); }
|
|
|
|
.cart-header { display: flex; align-items: center; justify-content: space-between; padding: var(--space-4) var(--space-5); border-bottom: 1px solid var(--color-border); flex-shrink: 0; }
|
|
.cart-header h3 { font-family: var(--font-heading); font-size: var(--text-h5); font-weight: var(--heading-weight-secondary); color: var(--color-text-primary); }
|
|
.cart-items { flex: 1; overflow-y: auto; padding: var(--space-3) var(--space-4); }
|
|
.cart-item { display: flex; gap: var(--space-3); padding: var(--space-3) 0; border-bottom: 1px solid var(--color-border); }
|
|
.cart-item:last-child { border-bottom: none; }
|
|
.cart-footer { padding: var(--space-4) var(--space-5); border-top: 1px solid var(--color-border); flex-shrink: 0; background: var(--color-bg-elevated); }
|
|
.cart-totals { display: flex; flex-direction: column; gap: var(--space-1); font-size: var(--text-body-sm); color: var(--color-text-secondary); }
|
|
.cart-overlay { position: fixed; inset: 0; z-index: calc(var(--z-modal) - 1); background: rgba(0,0,0,0.3); backdrop-filter: blur(2px); display: none; }
|
|
.cart-overlay.open { display: block; }
|
|
|
|
/* =========================================================================
|
|
THEME BAR
|
|
========================================================================= */
|
|
|
|
.theme-bar { position: fixed; top: 0; left: 0; right: 0; z-index: var(--z-toast); display: flex; align-items: center; justify-content: flex-end; gap: var(--space-2); padding: var(--space-2) var(--space-4); background: var(--color-bg-overlay); border-bottom: 1px solid var(--color-border); backdrop-filter: blur(8px); height: 36px; }
|
|
.theme-bar__label { font-size: var(--text-caption); color: var(--color-text-muted); font-family: var(--font-body); letter-spacing: var(--tracking-wide); text-transform: uppercase; }
|
|
.theme-btn { display: inline-flex; align-items: center; gap: var(--space-1); padding: 3px var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-full); background: transparent; color: var(--color-text-secondary); font-family: var(--font-body); font-size: var(--text-caption); font-weight: var(--font-weight-semibold); cursor: pointer; transition: var(--transition-fast); }
|
|
.theme-btn:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
|
.theme-btn.is-active { background: var(--color-primary); border-color: var(--color-primary); color: var(--color-text-inverse); }
|
|
|
|
/* =========================================================================
|
|
RESPONSIVE
|
|
========================================================================= */
|
|
|
|
@media (max-width: 768px) {
|
|
.sidebar { position: fixed; left: -260px; z-index: var(--z-modal); transition: left var(--duration-normal) var(--ease-in-out); height: 100vh; }
|
|
.sidebar.is-open { left: 0; }
|
|
.search-bar { width: 200px; }
|
|
.detail-panel { width: 100%; }
|
|
.nav-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
|
|
<!-- Theme switcher bar -->
|
|
<div class="theme-bar" role="toolbar" aria-label="Cambiar tema">
|
|
<span class="theme-bar__label">Tema:</span>
|
|
<button class="theme-btn is-active" data-theme-switch="industrial" aria-pressed="true">
|
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12 2l2.4 1.6L17 3l1 2.6 2.4 1.6-.2 2.8L22 12l-1.8 2 .2 2.8-2.4 1.6-1 2.6-2.6-.2L12 22l-2.4-1.6L7 21l-1-2.6-2.4-1.6.2-2.8L2 12l1.8-2-.2-2.8L6 5.6 7 3l2.6.2z"/><circle cx="12" cy="12" r="3"/></svg>
|
|
Industrial
|
|
</button>
|
|
<button class="theme-btn" data-theme-switch="modern" aria-pressed="false">
|
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>
|
|
Moderno
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Mobile sidebar overlay -->
|
|
<div class="sidebar-overlay" id="sidebarOverlay" role="presentation"></div>
|
|
|
|
<!-- APP SHELL -->
|
|
<div class="app-shell">
|
|
|
|
<!-- SIDEBAR -->
|
|
<aside class="sidebar themed-scrollbar" id="sidebar" role="navigation" aria-label="Menu principal">
|
|
<div class="sidebar__brand">
|
|
<div class="brand-logo" aria-hidden="true">N</div>
|
|
<div class="brand-name">
|
|
<span class="brand-name__primary">Nexus</span>
|
|
<span class="brand-name__sub">Autoparts POS</span>
|
|
</div>
|
|
</div>
|
|
<nav class="sidebar__nav">
|
|
<div class="nav-section-label">Principal</div>
|
|
<a class="nav-item" href="/pos/dashboard" role="menuitem">
|
|
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
|
|
Dashboard
|
|
</a>
|
|
<a class="nav-item" href="/pos/sale" role="menuitem">
|
|
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"/><path d="M3 6h18"/><path d="M16 10a4 4 0 01-8 0"/></svg>
|
|
Punto de Venta
|
|
</a>
|
|
<a class="nav-item" href="/pos/inventory" role="menuitem">
|
|
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"/></svg>
|
|
Inventario
|
|
</a>
|
|
<a class="nav-item is-active" href="/pos/catalog" role="menuitem" aria-current="page">
|
|
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M8 7h8M8 12h8M8 17h5"/></svg>
|
|
Catalogo
|
|
</a>
|
|
<div class="nav-section-label" style="margin-top: var(--space-2);">Gestion</div>
|
|
<a class="nav-item" href="/pos/customers" role="menuitem">
|
|
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>
|
|
Clientes
|
|
</a>
|
|
<a class="nav-item" href="/pos/invoicing" role="menuitem">
|
|
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14,2 14,8 20,8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
|
Facturacion
|
|
</a>
|
|
<a class="nav-item" href="/pos/accounting" role="menuitem">
|
|
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6"/></svg>
|
|
Contabilidad
|
|
</a>
|
|
<a class="nav-item" href="/pos/reports" role="menuitem">
|
|
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><polyline points="22,12 18,12 15,21 9,3 6,12 2,12"/></svg>
|
|
Reportes
|
|
</a>
|
|
<div class="nav-section-label" style="margin-top: var(--space-2);">Sistema</div>
|
|
<a class="nav-item" href="/pos/config" role="menuitem">
|
|
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M6.34 17.66l-1.41 1.41M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M12 2v2M12 20v2M2 12h2M20 12h2"/></svg>
|
|
Configuracion
|
|
</a>
|
|
</nav>
|
|
<div class="sidebar__profile">
|
|
<div class="profile-avatar" id="profileAvatar" aria-hidden="true">--</div>
|
|
<div class="profile-info">
|
|
<div class="profile-info__name" id="profileName">--</div>
|
|
<div class="profile-info__role" id="profileRole">--</div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<!-- MAIN CONTENT -->
|
|
<main class="main-content">
|
|
|
|
<!-- Header: breadcrumb + search -->
|
|
<header class="content-header">
|
|
<nav class="breadcrumb" id="breadcrumb" aria-label="Navegacion del catalogo">
|
|
<span class="breadcrumb__current">Catalogo</span>
|
|
</nav>
|
|
<div class="header-actions" style="position:relative;">
|
|
<div class="search-bar" id="searchBar">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
|
<input type="text" id="searchInput" placeholder="Buscar por numero de parte o nombre... (F1)" autocomplete="off" />
|
|
</div>
|
|
<div class="search-dropdown" id="searchDropdown"></div>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Scrollable page body -->
|
|
<div class="page-body" id="pageBody">
|
|
|
|
<!-- Level title + optional filter -->
|
|
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-4); flex-wrap:wrap;">
|
|
<h2 class="level-title" id="levelTitle">Selecciona una marca</h2>
|
|
<input type="text" class="level-filter" id="levelFilter" placeholder="Filtrar..." style="display:none;" />
|
|
</div>
|
|
|
|
<!-- Loading spinner -->
|
|
<div class="loading" id="loading"><div class="spinner"></div></div>
|
|
|
|
<!-- Empty state -->
|
|
<div class="empty-state" id="emptyState">
|
|
<div class="empty-state__title" id="emptyTitle">Sin resultados</div>
|
|
<div class="empty-state__subtitle" id="emptySubtitle">No se encontraron datos para este nivel.</div>
|
|
</div>
|
|
|
|
<!-- Navigation grid (brands, models, years, engines, categories, groups) -->
|
|
<div class="nav-grid" id="navGrid" role="list"></div>
|
|
|
|
<!-- Parts grid (only for parts level) -->
|
|
<div class="nav-grid nav-grid--parts" id="partsGrid" role="list" style="display:none;"></div>
|
|
|
|
<!-- Pagination -->
|
|
<nav class="pagination" id="pagination" aria-label="Paginacion"></nav>
|
|
|
|
</div>
|
|
</main>
|
|
</div>
|
|
|
|
<!-- Detail panel overlay -->
|
|
<div class="detail-overlay" id="detailOverlay"></div>
|
|
|
|
<!-- Detail panel (slide-in) -->
|
|
<aside class="detail-panel" id="detailPanel">
|
|
<div class="detail-header">
|
|
<h3>Detalle de parte</h3>
|
|
<button class="detail-close" id="detailClose" aria-label="Cerrar detalle">✕</button>
|
|
</div>
|
|
<div class="detail-body" id="detailBody">
|
|
<!-- Populated by JS -->
|
|
</div>
|
|
<div class="detail-footer" id="detailFooter">
|
|
<div class="qty-row">
|
|
<button class="qty-btn" id="qtyMinus">-</button>
|
|
<span class="qty-display" id="qtyDisplay">1</span>
|
|
<button class="qty-btn" id="qtyPlus">+</button>
|
|
</div>
|
|
<button class="btn btn-primary" id="addToCartBtn" style="width:100%;">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 002 1.61h9.72a2 2 0 002-1.61L23 6H6"/></svg>
|
|
Agregar al carrito
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
|
|
<!-- Cart FAB -->
|
|
<button class="cart-fab" id="cartFab" aria-label="Abrir carrito">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 002 1.61h9.72a2 2 0 002-1.61L23 6H6"/></svg>
|
|
<span class="cart-fab__badge" id="cartBadge">0</span>
|
|
</button>
|
|
|
|
<!-- Cart overlay -->
|
|
<div class="cart-overlay" id="cartOverlay"></div>
|
|
|
|
<!-- Cart sidebar -->
|
|
<aside class="cart-sidebar" id="cartSidebar">
|
|
<div class="cart-header">
|
|
<h3>Carrito</h3>
|
|
<button id="cartCloseBtn" style="background:none;border:none;cursor:pointer;font-size:1.4rem;color:var(--color-text-secondary);padding:var(--space-1);">✕</button>
|
|
</div>
|
|
<div class="cart-items" id="cartItems"></div>
|
|
<div class="cart-empty" id="cartEmpty" style="display:none;padding:2rem;text-align:center;color:var(--color-text-muted);">Carrito vacio</div>
|
|
<div class="cart-footer">
|
|
<div class="cart-totals">
|
|
<div>Subtotal: <span id="cartSubtotal">$0.00</span></div>
|
|
<div>IVA 16%: <span id="cartTax">$0.00</span></div>
|
|
<div style="font-weight:bold;font-size:1.2em;">Total: <span id="cartTotal">$0.00</span></div>
|
|
</div>
|
|
<button id="checkoutBtn" class="btn btn-primary" style="width:100%;margin-top:var(--space-3);">Ir a cobrar →</button>
|
|
</div>
|
|
</aside>
|
|
|
|
<!-- Offline Banner -->
|
|
<div id="offlineBanner" class="banner banner--warning" style="display:none;position:fixed;top:0;left:0;right:0;z-index:9999;border-radius:0;">
|
|
<span class="banner__text" id="offlineBannerText"><strong>Modo offline</strong> — Mostrando solo tu inventario local.</span>
|
|
<button class="banner__dismiss" onclick="document.getElementById('offlineBanner').style.display='none'" aria-label="Cerrar">×</button>
|
|
</div>
|
|
|
|
<script src="/pos/static/js/app-init.js"></script>
|
|
<script src="/pos/static/js/sidebar.js"></script>
|
|
<script src="/pos/static/js/catalog.js"></script>
|
|
<script src="/pos/static/js/offline-banner.js"></script>
|
|
</body>
|
|
</html>
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Catalog JS rewrite
|
|
|
|
**Files:**
|
|
- Rewrite: `/home/Autopartes/pos/static/js/catalog.js`
|
|
|
|
Complete rewrite. Implements:
|
|
- Navigation state machine (levels: brands, models, years, engines, categories, groups, parts)
|
|
- Breadcrumb management
|
|
- Smart search with debounce
|
|
- Part detail panel
|
|
- Cart (localStorage, same format as before)
|
|
- Offline fallback to local inventory
|
|
- Barcode scanner support
|
|
- F1 focus search
|
|
|
|
- [ ] **Step 1: Rewrite catalog.js**
|
|
|
|
```javascript
|
|
// /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**
|
|
|
|
```python
|
|
# /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)`.
|