Compare commits

...

16 Commits

Author SHA1 Message Date
fb6ea31100 docs: add README, API reference, and architecture documentation
- README.md: project overview, features, quick start, API overview
- docs/API.md: full endpoint reference with examples
- docs/ARCHITECTURE.md: system diagram, DB schema, data pipeline, auth flow

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:30:53 +00:00
d269bc1ffb feat: add TecDoc import pipeline scripts
- import_tecdoc.py: 2-phase TecDoc download + import (brands, models, vehicles)
- import_live.py: real-time streaming importer for part details
- run_all_brands.sh: automated sequential brand processing pipeline

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:25:48 +00:00
5e6bf788db docs: add design and implementation plans
- SaaS + aftermarket design spec
- SaaS + aftermarket implementation plan (15 tasks)
- Captura partes design
- POS + cuentas design and plan

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:25:38 +00:00
fe6542c45c feat: add captura, POS, cuentas, and tienda pages
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:25:32 +00:00
b1adf536f6 feat: add demo catalog page with image display and part detail modal
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:25:27 +00:00
eff04a5e60 fix: stop creating AFT- placeholder parts in import pipeline
- import_phase1.py: skip AFT- part creation when no OEM data
- link_vehicle_parts.py: remove AFT- fallback lookup in part cache
- import_tecdoc_parts.py: add VW to TOP_BRANDS list

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:25:21 +00:00
4b01c57c88 feat: add aftermarket migration script — move AFT- parts to proper table
Migrates 357K AFT-prefixed parts from parts table to aftermarket_parts.
Parses part_number and manufacturer from AFT-{partNo}-{manufacturer} format.
Links to OEM parts via cross-references. Batch processing with progress.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:25:15 +00:00
e5d074687a feat: add users management tab to admin panel
New Sistema > Usuarios section with user listing, role badges
(ADMIN=blue, OWNER=purple, TALLER=green, BODEGA=orange),
activate/deactivate toggle, and pending users badge count.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:25:09 +00:00
6c6a9eecd6 feat: add auth UI to nav — login/logout button, bodega link
Shows business name + logout button when authenticated.
Shows login link when not authenticated. Adds bodega to nav links.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:25:03 +00:00
340d2fcef8 feat: add bodega dashboard — column mapping, inventory upload, listing
Three-tab panel for warehouse operators:
- Column mapping configuration (flexible CSV/Excel field mapping)
- File upload with drag-and-drop, progress tracking, error reporting
- Searchable paginated inventory view with clear-all option

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:24:58 +00:00
565f11aca6 feat: add login/register page with JWT auth flow
Login form with role-based redirect (ADMIN→demo, BODEGA→bodega, TALLER→demo).
Register form for TALLER/BODEGA with admin approval required.
Includes authFetch() wrapper with automatic token refresh.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:24:51 +00:00
744df6b3b8 feat: add SaaS endpoints — auth, inventory, availability, admin users
New endpoints:
- Auth: register, login, refresh, me
- Admin: list users, activate/deactivate
- Inventory: mapping CRUD, file upload (CSV/Excel), history, items list
- Parts: availability across warehouses, aftermarket alternatives
- Routes: login.html, bodega pages
- Fix: admin stats use pg_class estimates for large tables

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:24:45 +00:00
09d3304b21 feat: add JWT auth module — login, tokens, role-based middleware
Implements hash_password, check_password, create_access_token,
create_refresh_token, decode_token, and require_auth() decorator
for role-based endpoint protection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:24:38 +00:00
c5e5f6ef7e feat: add SaaS schema migration — sessions, inventory, mappings tables
Creates sessions, warehouse_inventory, inventory_uploads,
inventory_column_mappings tables. Extends users with business_name,
is_active, last_login. Updates roles to ADMIN/OWNER/TALLER/BODEGA.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:24:33 +00:00
6ef39d212c feat: add JWT auth and inventory dependencies
Add PyJWT, bcrypt, openpyxl to requirements.
Add JWT_SECRET, JWT_ACCESS_EXPIRES, JWT_REFRESH_EXPIRES to config.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:24:26 +00:00
f89d591fa9 chore: update .gitignore — exclude data/, WAL files, and diagram images
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:24:20 +00:00
44 changed files with 15223 additions and 519 deletions

10
.gitignore vendored
View File

@@ -51,3 +51,13 @@ Thumbs.db
# Backup files # Backup files
*.bak *.bak
*.backup *.backup
# Data files (TecDoc downloads, too large for git)
data/
# SQLite WAL files
*.db-shm
*.db-wal
# Diagram images (served from static, too large for git)
dashboard/static/diagrams/

430
README.md
View File

@@ -1,333 +1,205 @@
# Nexus Autoparts # Nexus Autoparts
Sistema completo de gestión de base de datos de vehículos y nexus-autoparts con dashboard web, herramientas de web scraping y múltiples interfaces de consulta. **Sistema de catalogo de autopartes con navegacion jerarquica, similar a 7zap.com/RockAuto.**
## Descripción Plataforma SaaS que conecta talleres con bodegas/distribuidores. Permite buscar partes OEM y aftermarket por vehiculo (marca, modelo, ano, motor), gestionar inventario de bodegas, y consultar disponibilidad y precios en tiempo real.
**Nexus Autoparts** es una solución integral para la gestión de información de vehículos que incluye: ## Tech Stack
- Base de datos SQLite normalizada con información de marcas, modelos, motores y años | Componente | Tecnologia |
- Dashboard web moderno y responsivo para consultar y explorar datos |------------|-----------|
- Herramientas de web scraping para recopilar datos de RockAuto.com | Backend | Python 3, Flask |
- Interfaces de línea de comandos (CLI) y programática | Base de datos | PostgreSQL |
- Scripts de utilidad para gestión y mantenimiento de datos | ORM / SQL | SQLAlchemy (`text()` raw SQL) |
| Autenticacion | JWT (PyJWT) + bcrypt |
| Data import | TecDoc via Apify, NHTSA VIN API |
| Frontend | HTML/CSS/JS vanilla (sin framework) |
| Dependencias extra | openpyxl (Excel), csv (CSV import) |
## Estadísticas de la Base de Datos ## Estadisticas de la Base de Datos
| Elemento | Cantidad | - **1.4M+** partes OEM
|----------|----------| - **300K+** partes aftermarket
| Marcas | 12 | - **13M+** cross-references (numeros alternos, supersesiones, intercambios)
| Modelos | 10,923 | - **12B+** vehicle-part links (fitment)
| Motores | 10,919 | - **100+** marcas, miles de modelos, anos 1956-2026
| Combinaciones modelo-año-motor | 12,075 |
## Tecnologías Utilizadas ## Features
### Backend - **Catalogo de autopartes** con navegacion jerarquica: Marca > Modelo > Ano > Motor > Categoria > Grupo > Parte
- **Python 3** - Lenguaje principal - **TecDoc integration** (via Apify) para importar datos OEM y aftermarket de Europa/Mexico
- **SQLite 3** - Base de datos - **SaaS multi-tenant** con roles: `ADMIN`, `OWNER`, `TALLER`, `BODEGA`
- **Flask 2.3.3** - Framework web - **JWT authentication** con access tokens (15 min) y refresh tokens (30 dias)
- **BeautifulSoup4** - Web scraping - **Gestion de inventario** para bodegas con mapeo flexible de columnas CSV/Excel
- **requests** - HTTP client - **Disponibilidad de partes** en multiples bodegas con precios comparativos
- **lxml** - Parser XML/HTML - **Alternativas aftermarket** con cross-references por cada parte OEM
- **Panel de administracion** con gestion de usuarios, import/export CSV, CRUD de categorias/grupos/partes/fabricantes/fitment
- **Busqueda full-text** en el catalogo de partes (PostgreSQL `tsvector`)
- **Busqueda combinada** vehiculo + parte (e.g., "Toyota Corolla 2020 frenos")
- **VIN decoder** via NHTSA API con cache en base de datos
- **Diagramas explosionados** con hotspots clickeables
- **Vehicle-to-part linking** (12B+ vehicle_parts links)
### Frontend ## Quick Start
- **HTML5** - Estructura
- **Bootstrap 5.3.0** - Framework CSS
- **JavaScript (ES6+)** - Lógica cliente
- **Font Awesome 6.0.0** - Iconos
## Estructura del Proyecto ### Requisitos previos
``` - Python 3.8+
Autopartes/ - PostgreSQL con la base `nexus_autoparts`
├── vehicle_database/ # Sistema principal de base de datos
│ ├── sql/
│ │ └── schema.sql # Esquema de la base de datos
│ ├── scripts/
│ │ ├── database_manager.py # Gestión de la BD
│ │ ├── query_interface.py # Interfaz CLI
│ │ └── csv_importer.py # Importador CSV
│ ├── data/
│ │ ├── brands.csv # Datos de marcas
│ │ ├── engines.csv # Datos de motores
│ │ └── models.csv # Datos de modelos
│ ├── vehicle_database.db # Base de datos SQLite
│ └── setup.sh # Script de inicialización
├── dashboard/ # Interfaz web
│ ├── server.py # Backend Flask
│ ├── index.html # Frontend HTML
│ ├── dashboard.js # Lógica JavaScript
│ └── start_dashboard.sh # Script de inicio
├── console/ # Consola Pick/VT220
│ ├── main.py # Punto de entrada
│ ├── db.py # Capa de datos abstracta
│ ├── core/ # Framework (app, screens, nav, keys)
│ ├── screens/ # 14 pantallas (menú, CRUD, búsqueda)
│ ├── renderers/ # Renderer VT220 (curses)
│ ├── utils/ # Formato y API VIN
│ └── tests/ # 116 tests
├── vehicle_scraper/ # Herramientas de web scraping
│ ├── rockauto_scraper.py # Scraper RockAuto
│ ├── rockauto_scraper_v2.py # Scraper mejorado
│ ├── scrape_toyota.py # Scraper Toyota
│ ├── scrape_nissan_ford_chevrolet.py
│ └── manual_input.py # Ingreso manual
├── add_*.py # Scripts para agregar datos
├── remove_*.py # Scripts de limpieza
└── QUICK_START.sh # Guía rápida de inicio
```
## Consola Pick/VT220 ### Instalacion
Interfaz de terminal inspirada en los sistemas Pick/D3, 100% operada con teclado. Estética verde sobre negro con caracteres de caja, sin dependencias externas.
```bash ```bash
python -m console cd /home/Autopartes
pip install -r requirements.txt
``` ```
Funcionalidades: navegación por vehículo (marca→modelo→año→motor), búsqueda por número de parte, búsqueda full-text, decodificador VIN (NHTSA), catálogo por categorías, comparador OEM vs aftermarket, y administración CRUD completa. ### Ejecutar el servidor
116 tests automatizados. Ver [`console/README.md`](console/README.md) para documentación completa.
## Instalación
### Requisitos Previos
- Python 3.8 o superior
- pip (gestor de paquetes de Python)
### Pasos de Instalación
1. **Clonar el repositorio**
```bash
git clone https://git.consultoria-as.com/[usuario]/Nexus-Autoparts.git
cd Nexus-Autoparts
```
2. **Instalar dependencias**
```bash
pip install flask requests beautifulsoup4 lxml
```
3. **Inicializar la base de datos (opcional - ya incluye datos)**
```bash
cd vehicle_database
./setup.sh
```
## Uso
### Iniciar el Dashboard Web
```bash ```bash
cd dashboard cd /home/Autopartes/dashboard
python3 server.py python3 server.py
``` ```
El dashboard estará disponible en: `http://localhost:5000` El servidor arranca en `http://localhost:5000`.
### Iniciar la Consola Pick/VT220 ### Importar datos de TecDoc
```bash ```bash
python -m console # Fase 1: descargar datos de TecDoc a JSON
python3 scripts/import_tecdoc.py download
# Fase 2: importar JSON a PostgreSQL
python3 scripts/import_tecdoc.py import
# Ver progreso
python3 scripts/import_tecdoc.py status
``` ```
### Usar la Interfaz CLI Legacy ### Importar partes y linkar vehiculos
```bash ```bash
cd vehicle_database/scripts # Importar partes TecDoc (OEM + aftermarket)
python3 query_interface.py python3 scripts/import_tecdoc_parts.py
# Importar datos en vivo desde TecDoc API
python3 scripts/import_live.py
# Crear links vehiculo-parte (fitment masivo)
python3 scripts/link_vehicle_parts.py
# Migrar datos aftermarket
python3 scripts/migrate_aftermarket.py
# Aplicar schema SaaS (roles, users, inventory tables)
python3 scripts/migrate_saas_schema.py
``` ```
### Ejecutar Web Scraping ## Paginas del Dashboard
```bash | Ruta | Archivo | Descripcion |
cd vehicle_scraper |------|---------|-------------|
python3 rockauto_scraper_v2.py | `/login.html` | `login.html` | Login con JWT |
``` | `/demo.html` | `demo.html` | Catalogo publico / demo |
| `/admin` | `admin.html` | Panel de administracion (ADMIN/OWNER) |
| `/bodega.html` | `bodega.html` | Gestion de inventario para bodegas |
| `/tienda.html` | `tienda.html` | Vista de tienda/catalogo para talleres |
| `/pos.html` | `pos.html` | Punto de venta |
| `/captura.html` | `captura.html` | Captura de partes |
| `/cuentas.html` | `cuentas.html` | Gestion de cuentas |
### Agregar Datos Manualmente ## API Overview
```bash Documentacion completa en [`docs/API.md`](docs/API.md).
cd vehicle_scraper
python3 manual_input.py
```
## API REST ### Auth (`/api/auth/`)
- `POST /api/auth/register` - Registrar usuario (TALLER/BODEGA)
- `POST /api/auth/login` - Login, retorna access + refresh tokens
- `POST /api/auth/refresh` - Renovar access token
- `GET /api/auth/me` - Info del usuario autenticado
El dashboard expone los siguientes endpoints: ### Catalogo (`/api/`)
- `GET /api/brands` - Listar marcas
- `GET /api/models?brand=X` - Modelos por marca
- `GET /api/years?brand=X&model=Y` - Anos disponibles
- `GET /api/engines?brand=X&model=Y&year=Z` - Motores disponibles
- `GET /api/categories` - Categorias de partes (arbol jerarquico)
- `GET /api/parts?group_id=X` - Partes por grupo
- `GET /api/parts/{id}/alternatives` - Alternativas aftermarket
- `GET /api/parts/{id}/cross-references` - Cross-references
- `GET /api/search?q=...` - Busqueda combinada (vehiculos + partes + aftermarket)
| Endpoint | Método | Descripción | ### Inventario (`/api/inventory/`)
|----------|--------|-------------| - `GET/PUT /api/inventory/mapping` - Mapeo de columnas CSV
| `/api/brands` | GET | Obtiene todas las marcas | - `POST /api/inventory/upload` - Subir CSV/Excel de inventario
| `/api/models?brand=X` | GET | Obtiene modelos por marca | - `GET /api/inventory/items` - Listar inventario propio
| `/api/years` | GET | Obtiene años disponibles | - `DELETE /api/inventory/items` - Limpiar inventario
| `/api/engines` | GET | Obtiene motores disponibles |
| `/api/vehicles` | GET | Búsqueda con filtros |
### Ejemplo de Uso ### Disponibilidad y Aftermarket
- `GET /api/parts/{id}/availability` - Bodegas con stock (auth: TALLER/ADMIN/OWNER)
- `GET /api/parts/{id}/aftermarket` - Alternativas aftermarket + cross-refs (publico)
```bash ### Admin (`/api/admin/`)
# Obtener todas las marcas - `GET /api/admin/users` - Listar usuarios (auth: ADMIN/OWNER)
curl http://localhost:5000/api/brands - `PUT /api/admin/users/{id}/activate` - Activar/desactivar usuario
- `GET /api/admin/stats` - Estadisticas del catalogo
- CRUD completo: categories, groups, parts, manufacturers, aftermarket, crossref, fitment
- Import/Export CSV: `POST /api/admin/import/{type}`, `GET /api/admin/export/{type}`
# Buscar vehículos por marca y año ### VIN Decoder
curl "http://localhost:5000/api/vehicles?brand=Toyota&year=2020" - `GET /api/vin/decode/{vin}` - Decodificar VIN via NHTSA API
``` - `GET /api/vin/{vin}/parts` - Partes para un VIN decodificado
- `GET /api/vin/{vin}/match?mye_id=X` - Vincular VIN manualmente a vehiculo
## Esquema de Base de Datos ## Scripts
### Tablas | Script | Funcion |
|--------|---------|
| `import_tecdoc.py` | Descarga datos de TecDoc API (vehiculos, modelos, marcas) a JSON |
| `import_tecdoc_parts.py` | Importa partes OEM y aftermarket desde TecDoc |
| `import_live.py` | Importacion en vivo desde TecDoc API |
| `link_vehicle_parts.py` | Genera links vehiculo-parte (fitment masivo) |
| `migrate_aftermarket.py` | Migra datos aftermarket a la estructura normalizada |
| `migrate_saas_schema.py` | Crea tablas SaaS: sessions, warehouse_inventory, roles, etc. |
| `import_phase1.py` | Importacion inicial fase 1 |
| `run_all_brands.sh` | Script auxiliar para importar todas las marcas |
#### brands ## Configuracion
| Campo | Tipo | Descripción |
|-------|------|-------------|
| id | INTEGER | Clave primaria |
| name | TEXT | Nombre de la marca |
| country | TEXT | País de origen |
| founded_year | INTEGER | Año de fundación |
#### models Archivo principal: [`config.py`](config.py)
| Campo | Tipo | Descripción |
|-------|------|-------------|
| id | INTEGER | Clave primaria |
| brand_id | INTEGER | FK a brands |
| name | TEXT | Nombre del modelo |
| body_type | TEXT | Tipo de carrocería |
| generation | TEXT | Generación |
| production_start_year | INTEGER | Año inicio producción |
| production_end_year | INTEGER | Año fin producción |
#### engines | Variable | Default | Descripcion |
| Campo | Tipo | Descripción | |----------|---------|-------------|
|-------|------|-------------| | `DATABASE_URL` | `postgresql://nexus:...@localhost/nexus_autoparts` | PostgreSQL connection string |
| id | INTEGER | Clave primaria | | `JWT_SECRET` | `nexus-saas-secret-change-in-prod-2026` | Secreto para firmar tokens JWT |
| name | TEXT | Nombre del motor | | `JWT_ACCESS_EXPIRES` | `900` (15 min) | Duracion del access token en segundos |
| displacement_cc | INTEGER | Cilindrada en cc | | `JWT_REFRESH_EXPIRES` | `2592000` (30 dias) | Duracion del refresh token en segundos |
| cylinders | INTEGER | Número de cilindros |
| fuel_type | TEXT | Tipo de combustible |
| power_hp | INTEGER | Potencia en HP |
| torque_nm | INTEGER | Torque en Nm |
| engine_code | TEXT | Código del motor |
#### years ## Arquitectura
| Campo | Tipo | Descripción |
|-------|------|-------------|
| id | INTEGER | Clave primaria |
| year | INTEGER | Año |
#### model_year_engine Documentacion detallada en [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).
| Campo | Tipo | Descripción |
|-------|------|-------------|
| id | INTEGER | Clave primaria |
| model_id | INTEGER | FK a models |
| year_id | INTEGER | FK a years |
| engine_id | INTEGER | FK a engines |
| trim_level | TEXT | Nivel de equipamiento |
| drivetrain | TEXT | Tracción |
| transmission | TEXT | Transmisión |
### Diagrama de Relaciones
``` ```
brands ──┐ +------------------+
| TecDoc (Apify) |
├──< models ──┐ +--------+---------+
|
years ───┼─────────────┼──< model_year_engine download/import
|
engines ─┴─────────────┘
```
## Scripts Disponibles
### Scripts de Datos
| Script | Descripción |
|--------|-------------|
| `add_toyota_data.py` | Agrega datos de Toyota |
| `add_honda_data.py` | Agrega datos de Honda |
| `add_nissan_data.py` | Agrega datos de Nissan |
| `add_ford_data.py` | Agrega datos de Ford |
| `add_chevrolet_data.py` | Agrega datos de Chevrolet |
| `add_audi_data.py` | Agrega datos de Audi |
| `add_acura_data.py` | Agrega datos de Acura |
| ... | Y más marcas |
### Scripts de Mantenimiento
| Script | Descripción |
|--------|-------------|
| `remove_brands_and_cleanup.py` | Limpia marcas innecesarias |
| `check_and_remove_brands.py` | Verifica y elimina marcas |
## Funcionalidades del Dashboard
### Panel de Filtros
- Selección de marca
- Selección de modelo (dinámico según marca)
- Filtro por año
- Filtro por motor
### Panel de Resultados
- Visualización en tarjetas
- Información detallada del vehículo
- Especificaciones del motor
- Datos de transmisión y tracción
### Características
- Diseño responsivo
- Actualización en tiempo real
- Animaciones y transiciones suaves
- Soporte para múltiples idiomas
## Arquitectura del Sistema
```
┌─────────────────┐ ┌──────────────────┐
│ RockAuto.com │────>│ Web Scraper │
└─────────────────┘ └────────┬─────────┘
v v
┌─────────────────┐ ┌──────────────────┐ +----------+ +--------+---------+ +----------------+
│ Manual Input │────>│ SQLite Database │ | Frontend |<--->| Flask Server |<--->| PostgreSQL |
└─────────────────┘ └────────┬─────────┘ | (HTML/JS)| | (server.py) | | nexus_autoparts|
+----------+ +--------+---------+ +----------------+
┌───────────────────────┼───────────────────────┐ |
│ │ JWT auth (PyJWT)
v v v |
┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐ +------------+------------+
│ Flask API Pick Console CSV Importer | | |
└────────┬────────┘ (VT220/Rich) └──────────────────┘ TALLER BODEGA ADMIN
│ └──────────────────┘ (consulta) (inventario) (gestion)
v
┌─────────────────┐
│ Web Dashboard │
│ (Browser) │
└─────────────────┘
``` ```
## Contribuir
1. Fork el repositorio
2. Crea una rama para tu feature (`git checkout -b feature/nueva-funcionalidad`)
3. Commit tus cambios (`git commit -am 'Agrega nueva funcionalidad'`)
4. Push a la rama (`git push origin feature/nueva-funcionalidad`)
5. Crea un Pull Request
## Licencia
Este proyecto es de uso interno.
## Contacto
Para más información, contactar al equipo de desarrollo.
--- ---
**Nexus Autoparts** - Sistema de Gestión de Base de Datos de Vehículos **Nexus Autoparts** - Tu conexion directa con las partes que necesitas

View File

@@ -15,6 +15,11 @@ SQLITE_PATH = os.path.join(
"vehicle_database", "vehicle_database.db" "vehicle_database", "vehicle_database.db"
) )
# JWT Authentication
JWT_SECRET = os.environ.get("JWT_SECRET", "nexus-saas-secret-change-in-prod-2026")
JWT_ACCESS_EXPIRES = 900 # 15 minutes
JWT_REFRESH_EXPIRES = 2592000 # 30 days
# Application identity # Application identity
APP_NAME = "NEXUS AUTOPARTS" APP_NAME = "NEXUS AUTOPARTS"
APP_SLOGAN = "Tu conexión directa con las partes que necesitas" APP_SLOGAN = "Tu conexión directa con las partes que necesitas"

View File

@@ -668,6 +668,15 @@
<span>Exportar CSV</span> <span>Exportar CSV</span>
</div> </div>
</div> </div>
<div class="sidebar-section">
<h3>Sistema</h3>
<div class="sidebar-item" data-section="users">
<span class="icon">👤</span>
<span>Usuarios</span>
<span class="badge" id="pendingUsersBadge" style="display:none; background:var(--warning); color:#000; font-size:0.7rem; padding:2px 6px; border-radius:10px; margin-left:auto;"></span>
</div>
</div>
</aside> </aside>
<!-- Main Content --> <!-- Main Content -->
@@ -1207,6 +1216,35 @@
</div> </div>
</div> </div>
</section> </section>
<!-- Users Section -->
<section id="section-users" class="admin-section">
<div class="page-header">
<h1 class="page-title">Usuarios</h1>
</div>
<div class="card">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Nombre</th>
<th>Email</th>
<th>Negocio</th>
<th>Rol</th>
<th>Activo</th>
<th>Último Login</th>
<th>Acciones</th>
</tr>
</thead>
<tbody id="usersTable">
<tr><td colspan="7" class="loading"><div class="spinner"></div></td></tr>
</tbody>
</table>
</div>
</div>
</section>
</main> </main>
</div> </div>

View File

@@ -118,6 +118,9 @@ function showSection(sectionId) {
case 'diagrams': case 'diagrams':
// Just show section, user uses search // Just show section, user uses search
break; break;
case 'users':
loadUsers();
break;
} }
} }
@@ -1226,18 +1229,18 @@ function renderPagination(containerId, pagination, pageKey, loadFunction) {
let html = ''; let html = '';
// Previous button // Previous button
html += `<button ${page <= 1 ? 'disabled' : ''} onclick="goToPage('${pageKey}', ${page - 1}, ${loadFunction.name})">← Anterior</button>`; html += `<button ${page <= 1 ? 'disabled' : ''} onclick="goToPage('${pageKey}', ${page - 1}, '${loadFunction.name}')">← Anterior</button>`;
// Page numbers // Page numbers
const startPage = Math.max(1, page - 2); const startPage = Math.max(1, page - 2);
const endPage = Math.min(total_pages, page + 2); const endPage = Math.min(total_pages, page + 2);
for (let i = startPage; i <= endPage; i++) { for (let i = startPage; i <= endPage; i++) {
html += `<button class="${i === page ? 'active' : ''}" onclick="goToPage('${pageKey}', ${i}, ${loadFunction.name})">${i}</button>`; html += `<button class="${i === page ? 'active' : ''}" onclick="goToPage('${pageKey}', ${i}, '${loadFunction.name}')">${i}</button>`;
} }
// Next button // Next button
html += `<button ${page >= total_pages ? 'disabled' : ''} onclick="goToPage('${pageKey}', ${page + 1}, ${loadFunction.name})">Siguiente →</button>`; html += `<button ${page >= total_pages ? 'disabled' : ''} onclick="goToPage('${pageKey}', ${page + 1}, '${loadFunction.name}')">Siguiente →</button>`;
container.innerHTML = html; container.innerHTML = html;
} }
@@ -1967,3 +1970,107 @@ async function deleteHotspot(hotspotId) {
showAlert(e.message, 'error'); showAlert(e.message, 'error');
} }
} }
// ============================================================================
// User Management
// ============================================================================
const roleBadgeColors = {
ADMIN: '#3b82f6',
OWNER: '#8b5cf6',
TALLER: '#22c55e',
BODEGA: '#f59e0b'
};
function formatDate(dateStr) {
if (!dateStr) return '<span style="color:var(--text-secondary)">Nunca</span>';
var d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr;
return d.toLocaleDateString('es-MX', { year: 'numeric', month: 'short', day: 'numeric' })
+ ' ' + d.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' });
}
function getRoleBadge(role) {
var color = roleBadgeColors[role] || '#6b7280';
return '<span style="background:' + color + '; color:#fff; padding:2px 8px; border-radius:10px; font-size:0.75rem; font-weight:600;">' + (role || 'N/A') + '</span>';
}
function getActiveBadge(isActive) {
if (isActive) {
return '<span style="background:var(--success); color:#000; padding:2px 8px; border-radius:10px; font-size:0.75rem; font-weight:600;">Activo</span>';
}
return '<span style="background:#ef4444; color:#fff; padding:2px 8px; border-radius:10px; font-size:0.75rem; font-weight:600;">Inactivo</span>';
}
async function loadUsers() {
var token = localStorage.getItem('access_token');
var tbody = document.getElementById('usersTable');
tbody.innerHTML = '<tr><td colspan="7" class="loading"><div class="spinner"></div></td></tr>';
try {
var res = await fetch('/api/admin/users', {
headers: { 'Authorization': 'Bearer ' + token }
});
if (!res.ok) throw new Error('Error al cargar usuarios (' + res.status + ')');
var data = await res.json();
var users = Array.isArray(data) ? data : (data.data || []);
// Update pending badge
var pending = users.filter(function(u) { return !u.is_active; }).length;
var badge = document.getElementById('pendingUsersBadge');
if (badge) {
if (pending > 0) {
badge.textContent = pending;
badge.style.display = 'inline-block';
} else {
badge.style.display = 'none';
}
}
if (users.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center; color:var(--text-secondary); padding:2rem;">No hay usuarios registrados</td></tr>';
return;
}
tbody.innerHTML = users.map(function(u) {
var toggleLabel = u.is_active ? 'Desactivar' : 'Activar';
var toggleClass = u.is_active ? 'btn-secondary' : 'btn-primary';
return '<tr>' +
'<td>' + (u.name || u.nombre || '-') + '</td>' +
'<td>' + (u.email || '-') + '</td>' +
'<td>' + (u.business_name || u.negocio || '-') + '</td>' +
'<td>' + getRoleBadge(u.role || u.rol) + '</td>' +
'<td>' + getActiveBadge(u.is_active) + '</td>' +
'<td>' + formatDate(u.last_login || u.ultimo_login) + '</td>' +
'<td><button class="btn ' + toggleClass + '" style="font-size:0.8rem; padding:4px 10px;" onclick="toggleUserActive(' + u.id + ', ' + u.is_active + ')">' + toggleLabel + '</button></td>' +
'</tr>';
}).join('');
} catch (e) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center; color:#ef4444; padding:2rem;">' + e.message + '</td></tr>';
}
}
async function toggleUserActive(userId, currentActive) {
var token = localStorage.getItem('access_token');
var action = currentActive ? 'desactivar' : 'activar';
if (!confirm('¿Seguro que deseas ' + action + ' este usuario?')) return;
try {
var res = await fetch('/api/admin/users/' + userId + '/activate', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token
},
body: JSON.stringify({ is_active: !currentActive })
});
if (!res.ok) {
var err = await res.json();
throw new Error(err.error || 'Error al actualizar usuario');
}
showAlert('Usuario ' + (currentActive ? 'desactivado' : 'activado') + ' correctamente');
loadUsers();
} catch (e) {
showAlert(e.message, 'error');
}
}

98
dashboard/auth.py Normal file
View File

@@ -0,0 +1,98 @@
"""
JWT authentication module for Nexus Autoparts.
"""
import sys
import os
import secrets
from datetime import datetime, timedelta
from functools import wraps
import bcrypt
import jwt
import psycopg2
sys.path.insert(0, '/home/Autopartes')
from config import DB_URL, JWT_SECRET, JWT_ACCESS_EXPIRES, JWT_REFRESH_EXPIRES
from flask import request, g, jsonify
def hash_password(password):
"""Hash a password using bcrypt."""
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
def check_password(password, hashed):
"""Verify a password against a bcrypt hash."""
return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8'))
def create_access_token(user_id, role, business_name):
"""Create a JWT access token with 15-minute expiry."""
payload = {
'user_id': user_id,
'role': role,
'business_name': business_name,
'type': 'access',
'exp': datetime.utcnow() + timedelta(seconds=JWT_ACCESS_EXPIRES),
'iat': datetime.utcnow()
}
return jwt.encode(payload, JWT_SECRET, algorithm='HS256')
def create_refresh_token(user_id):
"""Create a random refresh token and store it in the sessions table."""
token = secrets.token_urlsafe(48)
expires_at = datetime.utcnow() + timedelta(seconds=JWT_REFRESH_EXPIRES)
conn = psycopg2.connect(DB_URL)
try:
with conn.cursor() as cur:
cur.execute(
"""INSERT INTO sessions (user_id, refresh_token, expires_at, created_at)
VALUES (%s, %s, %s, %s)""",
(user_id, token, expires_at, datetime.utcnow())
)
conn.commit()
finally:
conn.close()
return token
def decode_token(token):
"""Decode a JWT token. Returns the payload dict or None if invalid/expired."""
try:
return jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
return None
def require_auth(*allowed_roles):
"""Flask decorator that validates Bearer token, checks role, and sets g.user."""
def decorator(f):
@wraps(f)
def decorated(*args, **kwargs):
auth_header = request.headers.get('Authorization', '')
if not auth_header.startswith('Bearer '):
return jsonify({'error': 'Missing or invalid Authorization header'}), 401
token = auth_header[7:] # strip "Bearer "
payload = decode_token(token)
if payload is None:
return jsonify({'error': 'Invalid or expired token'}), 401
if payload.get('type') != 'access':
return jsonify({'error': 'Invalid token type'}), 401
if allowed_roles and payload.get('role') not in allowed_roles:
return jsonify({'error': 'Insufficient permissions'}), 403
g.user = {
'user_id': payload['user_id'],
'role': payload['role'],
'business_name': payload.get('business_name')
}
return f(*args, **kwargs)
return decorated
return decorator

485
dashboard/bodega.css Normal file
View File

@@ -0,0 +1,485 @@
/* ============================================================
bodega.css -- Styles for Nexus Autoparts Warehouse (Bodega)
============================================================ */
/* --- Layout --- */
.bodega-container {
max-width: 1100px;
margin: 0 auto;
padding: 5.5rem 2rem 3rem;
}
/* --- Tabs --- */
.bodega-tabs {
display: flex;
gap: 0;
border-bottom: 2px solid var(--border);
margin-bottom: 1.5rem;
}
.bodega-tab {
padding: 0.8rem 1.8rem;
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
border-bottom: 3px solid transparent;
transition: all 0.2s;
position: relative;
bottom: -2px;
}
.bodega-tab:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
.bodega-tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.bodega-section {
display: none;
}
.bodega-section.active {
display: block;
}
/* --- Section Intro --- */
.section-intro {
margin-bottom: 1.5rem;
}
.section-intro h2 {
font-size: 1.3rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.section-intro p {
color: var(--text-secondary);
font-size: 0.9rem;
line-height: 1.5;
}
/* --- Mapping Form --- */
.mapping-form {
max-width: 550px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
}
.mapping-form .form-group {
margin-bottom: 1.25rem;
}
.mapping-form .form-label {
display: block;
margin-bottom: 0.4rem;
font-weight: 500;
font-size: 0.9rem;
color: var(--text-primary);
}
.required {
color: var(--danger);
font-weight: 700;
}
.optional {
color: var(--text-secondary);
font-size: 0.8rem;
font-weight: 400;
}
.form-hint {
display: block;
margin-top: 0.3rem;
font-size: 0.75rem;
color: var(--text-secondary);
}
.form-actions {
display: flex;
align-items: center;
gap: 1rem;
margin-top: 1.25rem;
}
.status-msg {
font-size: 0.85rem;
font-weight: 500;
}
.status-msg.success {
color: var(--success);
}
.status-msg.error {
color: var(--danger);
}
/* --- Upload Zone --- */
.upload-zone {
border: 2px dashed var(--border);
border-radius: 12px;
padding: 3rem 2rem;
text-align: center;
cursor: pointer;
transition: all 0.3s;
background: var(--bg-card);
margin-bottom: 1rem;
}
.upload-zone:hover,
.upload-zone.dragover {
border-color: var(--accent);
background: rgba(255, 107, 53, 0.05);
}
.upload-icon {
font-size: 3rem;
margin-bottom: 0.75rem;
}
.upload-text {
font-size: 1rem;
font-weight: 500;
margin-bottom: 0.3rem;
}
.upload-hint {
font-size: 0.8rem;
color: var(--text-secondary);
}
/* --- Selected File --- */
.selected-file {
display: flex;
align-items: center;
gap: 0.75rem;
background: var(--bg-card);
border: 1px solid var(--accent);
border-radius: 8px;
padding: 0.6rem 1rem;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.btn-icon {
background: none;
border: none;
color: var(--text-secondary);
font-size: 1.2rem;
cursor: pointer;
padding: 0.1rem 0.3rem;
line-height: 1;
transition: color 0.2s;
}
.btn-icon:hover {
color: var(--danger);
}
/* --- Upload Result --- */
.upload-result {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.25rem;
margin-bottom: 1.5rem;
}
.upload-result h4 {
margin-bottom: 0.75rem;
font-size: 1rem;
}
.result-stats {
display: flex;
gap: 1.5rem;
margin-bottom: 0.75rem;
}
.result-stat {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.9rem;
}
.result-stat.ok {
color: var(--success);
}
.result-stat.err {
color: var(--danger);
}
.error-samples {
margin-top: 0.75rem;
padding: 0.75rem;
background: var(--bg-secondary);
border-radius: 8px;
font-size: 0.8rem;
color: var(--text-secondary);
max-height: 150px;
overflow-y: auto;
}
.error-samples p {
margin-bottom: 0.3rem;
}
/* --- History --- */
.history-section {
margin-top: 2rem;
}
.history-section h3 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 1rem;
}
/* --- Tables --- */
.table-wrap {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
}
.data-table thead th {
background: var(--bg-secondary);
color: var(--text-secondary);
text-transform: uppercase;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.05em;
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.data-table tbody td {
padding: 0.7rem 1rem;
border-bottom: 1px solid var(--border);
color: var(--text-primary);
}
.data-table tbody tr:hover {
background: var(--bg-hover);
}
.empty-row {
text-align: center;
color: var(--text-secondary);
padding: 2rem 1rem !important;
}
/* --- Status Badges --- */
.badge {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 10px;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
}
.badge-success {
background: rgba(34, 197, 94, 0.15);
color: var(--success);
}
.badge-error {
background: rgba(255, 68, 68, 0.15);
color: var(--danger);
}
.badge-pending {
background: rgba(245, 158, 11, 0.15);
color: var(--warning);
}
.badge-processing {
background: rgba(59, 130, 246, 0.15);
color: var(--info);
}
/* --- Inventory Toolbar --- */
.inventory-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.search-box {
display: flex;
gap: 0.5rem;
flex: 1;
max-width: 500px;
}
.search-box .form-input {
flex: 1;
}
.btn-danger {
background: rgba(255, 68, 68, 0.15);
border: 1px solid var(--danger);
color: var(--danger);
padding: 0.7rem 1.5rem;
border-radius: 10px;
font-weight: 600;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.3s;
}
.btn-danger:hover {
background: var(--danger);
color: white;
}
/* --- Pagination --- */
.bodega-pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 0.5rem;
margin-top: 1.25rem;
flex-wrap: wrap;
}
.bodega-pagination button {
padding: 0.4rem 0.8rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.bodega-pagination button:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.bodega-pagination button.active {
background: var(--accent);
border-color: var(--accent);
color: white;
}
.bodega-pagination button:disabled {
opacity: 0.4;
cursor: default;
}
/* --- Confirm Modal --- */
.confirm-box {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 14px;
padding: 2rem;
max-width: 420px;
width: 100%;
}
.confirm-box h3 {
margin-bottom: 0.75rem;
font-size: 1.1rem;
}
.confirm-box p {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 1.5rem;
line-height: 1.5;
}
.confirm-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
/* --- Toast --- */
#toast-container {
position: fixed;
bottom: 2rem;
right: 2rem;
z-index: 3000;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.toast {
padding: 0.8rem 1.2rem;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 500;
animation: fadeIn 0.3s ease;
max-width: 350px;
}
.toast.success {
background: rgba(34, 197, 94, 0.15);
border: 1px solid var(--success);
color: var(--success);
}
.toast.error {
background: rgba(255, 68, 68, 0.15);
border: 1px solid var(--danger);
color: var(--danger);
}
/* --- Responsive --- */
@media (max-width: 768px) {
.bodega-container {
padding: 5rem 1rem 2rem;
}
.bodega-tabs {
overflow-x: auto;
}
.bodega-tab {
padding: 0.7rem 1.2rem;
font-size: 0.85rem;
white-space: nowrap;
}
.inventory-toolbar {
flex-direction: column;
align-items: stretch;
}
.search-box {
max-width: none;
}
.result-stats {
flex-direction: column;
gap: 0.5rem;
}
}

164
dashboard/bodega.html Normal file
View File

@@ -0,0 +1,164 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bodega — NEXUS AUTOPARTS</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Orbitron:wght@700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/shared.css">
<link rel="stylesheet" href="/bodega.css">
</head>
<body>
<div id="shared-nav"></div>
<div class="bodega-container">
<!-- Main Tabs -->
<div class="bodega-tabs">
<button class="bodega-tab active" data-tab="mapeo">Mapeo de Columnas</button>
<button class="bodega-tab" data-tab="subir">Subir Inventario</button>
<button class="bodega-tab" data-tab="inventario">Mi Inventario</button>
</div>
<!-- ============================================ -->
<!-- TAB 1: Mapeo de Columnas -->
<!-- ============================================ -->
<div id="section-mapeo" class="bodega-section active">
<div class="section-intro">
<h2>Mapeo de Columnas</h2>
<p>Configura como se mapean las columnas de tu archivo CSV/Excel a los campos del sistema. Escribe el nombre exacto de la columna en tu archivo.</p>
</div>
<div class="mapping-form">
<div class="form-group">
<label class="form-label">Numero de Parte <span class="required">*</span></label>
<input id="map-part-number" type="text" class="form-input" placeholder="Ej: PartNo, SKU, Numero...">
<span class="form-hint">Campo del sistema: part_number</span>
</div>
<div class="form-group">
<label class="form-label">Precio <span class="required">*</span></label>
<input id="map-price" type="text" class="form-input" placeholder="Ej: Precio, Price, Costo...">
<span class="form-hint">Campo del sistema: price</span>
</div>
<div class="form-group">
<label class="form-label">Existencias <span class="required">*</span></label>
<input id="map-stock" type="text" class="form-input" placeholder="Ej: Stock, Qty, Existencia...">
<span class="form-hint">Campo del sistema: stock</span>
</div>
<div class="form-group">
<label class="form-label">Ubicacion / Sucursal <span class="optional">(opcional)</span></label>
<input id="map-location" type="text" class="form-input" placeholder="Ej: Sucursal, Bodega, Location...">
<span class="form-hint">Campo del sistema: location</span>
</div>
<div class="form-actions">
<button id="btn-save-mapping" class="btn btn-primary">Guardar Mapeo</button>
<span id="mapping-status" class="status-msg"></span>
</div>
</div>
</div>
<!-- ============================================ -->
<!-- TAB 2: Subir Inventario -->
<!-- ============================================ -->
<div id="section-subir" class="bodega-section">
<div class="section-intro">
<h2>Subir Inventario</h2>
<p>Sube un archivo CSV o Excel con tu inventario. Asegurate de haber configurado el mapeo de columnas primero.</p>
</div>
<div class="upload-zone" id="drop-zone">
<div class="upload-icon">&#128230;</div>
<p class="upload-text">Arrastra tu archivo aqui o haz clic para seleccionar</p>
<p class="upload-hint">CSV, XLS, XLSX — Max 10MB</p>
<input type="file" id="file-input" accept=".csv,.xls,.xlsx" style="display:none;">
</div>
<div id="selected-file" class="selected-file" style="display:none;">
<span id="selected-file-name"></span>
<button id="btn-clear-file" class="btn-icon" title="Quitar archivo">&times;</button>
</div>
<div class="form-actions">
<button id="btn-upload" class="btn btn-primary" disabled>Subir Archivo</button>
<span id="upload-status" class="status-msg"></span>
</div>
<div id="upload-result" class="upload-result" style="display:none;"></div>
<div class="history-section">
<h3>Historial de Cargas</h3>
<div id="upload-history" class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Archivo</th>
<th>Estado</th>
<th>Importados</th>
<th>Errores</th>
<th>Fecha</th>
</tr>
</thead>
<tbody id="history-body">
<tr><td colspan="5" class="empty-row">Cargando...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ============================================ -->
<!-- TAB 3: Mi Inventario -->
<!-- ============================================ -->
<div id="section-inventario" class="bodega-section">
<div class="section-intro">
<h2>Mi Inventario</h2>
</div>
<div class="inventory-toolbar">
<div class="search-box">
<input id="inv-search" type="text" class="form-input" placeholder="Buscar por numero de parte o nombre...">
<button id="btn-inv-search" class="btn btn-primary">Buscar</button>
</div>
<button id="btn-clear-all" class="btn btn-danger">Limpiar Todo</button>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Numero de Parte</th>
<th>Nombre</th>
<th>Precio</th>
<th>Existencias</th>
<th>Ubicacion</th>
<th>Actualizado</th>
</tr>
</thead>
<tbody id="inv-body">
<tr><td colspan="6" class="empty-row">Cargando...</td></tr>
</tbody>
</table>
</div>
<div id="inv-pagination" class="bodega-pagination"></div>
</div>
</div>
<!-- Confirm Modal -->
<div id="confirm-modal" class="modal-overlay">
<div class="confirm-box">
<h3 id="confirm-title">Confirmar</h3>
<p id="confirm-msg"></p>
<div class="confirm-actions">
<button id="confirm-cancel" class="btn btn-secondary">Cancelar</button>
<button id="confirm-ok" class="btn btn-danger">Confirmar</button>
</div>
</div>
</div>
<!-- Toast container -->
<div id="toast-container"></div>
<script src="/nav.js"></script>
<script src="/bodega.js"></script>
</body>
</html>

522
dashboard/bodega.js Normal file
View File

@@ -0,0 +1,522 @@
/**
* bodega.js — Warehouse (Bodega) dashboard for Nexus Autoparts
* Tabs: Mapeo de Columnas | Subir Inventario | Mi Inventario
*/
(function () {
'use strict';
var API = '';
var selectedFile = null;
var invPage = 1;
var invQuery = '';
// ================================================================
// Auth helpers
// ================================================================
function getToken() {
return localStorage.getItem('access_token') || '';
}
function getRole() {
var token = getToken();
if (!token) return null;
try {
var payload = JSON.parse(atob(token.split('.')[1]));
return payload.role || null;
} catch (e) {
return null;
}
}
function authHeaders(extra) {
var h = { 'Authorization': 'Bearer ' + getToken() };
if (extra) {
for (var k in extra) { h[k] = extra[k]; }
}
return h;
}
function checkAuth() {
var token = getToken();
var role = getRole();
if (!token || (role !== 'BODEGA' && role !== 'ADMIN')) {
window.location.href = '/login.html';
return false;
}
return true;
}
function tryRefreshToken() {
var refresh = localStorage.getItem('refresh_token');
if (!refresh) return Promise.reject(new Error('No refresh token'));
return fetch(API + '/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refresh })
}).then(function (r) {
if (!r.ok) throw new Error('Refresh failed');
return r.json();
}).then(function (data) {
if (data.access_token) {
localStorage.setItem('access_token', data.access_token);
}
return data;
});
}
// ================================================================
// API helper with 401 retry
// ================================================================
function api(path, opts) {
opts = opts || {};
if (!opts.headers) opts.headers = {};
opts.headers['Authorization'] = 'Bearer ' + getToken();
return fetch(API + path, opts).then(function (r) {
if (r.status === 401) {
return tryRefreshToken().then(function () {
opts.headers['Authorization'] = 'Bearer ' + getToken();
return fetch(API + path, opts);
}).then(function (r2) {
if (!r2.ok) return r2.json().then(function (d) { throw new Error(d.error || 'Error'); });
return r2.json();
});
}
if (!r.ok) return r.json().then(function (d) { throw new Error(d.error || 'Error'); });
return r.json();
});
}
// ================================================================
// Utilities
// ================================================================
function esc(s) {
if (!s) return '';
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function toast(msg, type) {
var container = document.getElementById('toast-container');
var el = document.createElement('div');
el.className = 'toast ' + (type || 'success');
el.textContent = msg;
container.appendChild(el);
setTimeout(function () { el.remove(); }, 3500);
}
function fmtDate(s) {
if (!s) return '—';
var d = new Date(s);
return d.toLocaleDateString('es-MX', {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit'
});
}
function fmtPrice(v) {
var n = parseFloat(v);
if (isNaN(n)) return '—';
return '$' + n.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function statusBadge(status) {
var map = {
'completed': { cls: 'badge-success', label: 'Completado' },
'success': { cls: 'badge-success', label: 'Completado' },
'error': { cls: 'badge-error', label: 'Error' },
'failed': { cls: 'badge-error', label: 'Fallido' },
'pending': { cls: 'badge-pending', label: 'Pendiente' },
'processing': { cls: 'badge-processing', label: 'Procesando' }
};
var info = map[(status || '').toLowerCase()] || { cls: 'badge-pending', label: status || 'Desconocido' };
return '<span class="badge ' + info.cls + '">' + esc(info.label) + '</span>';
}
// ================================================================
// Tab Switching
// ================================================================
document.querySelectorAll('.bodega-tab').forEach(function (tab) {
tab.addEventListener('click', function () {
document.querySelectorAll('.bodega-tab').forEach(function (t) { t.classList.remove('active'); });
document.querySelectorAll('.bodega-section').forEach(function (s) { s.classList.remove('active'); });
tab.classList.add('active');
var section = document.getElementById('section-' + tab.getAttribute('data-tab'));
if (section) section.classList.add('active');
// Load data when switching tabs
var target = tab.getAttribute('data-tab');
if (target === 'subir') loadUploadHistory();
if (target === 'inventario') loadInventory();
});
});
// ================================================================
// TAB 1: Mapeo de Columnas
// ================================================================
function loadMapping() {
api('/api/inventory/mapping').then(function (data) {
if (data.part_number) document.getElementById('map-part-number').value = data.part_number;
if (data.price) document.getElementById('map-price').value = data.price;
if (data.stock) document.getElementById('map-stock').value = data.stock;
if (data.location) document.getElementById('map-location').value = data.location;
}).catch(function () {
// No mapping yet — fields stay empty
});
}
document.getElementById('btn-save-mapping').addEventListener('click', function () {
var partNumber = document.getElementById('map-part-number').value.trim();
var price = document.getElementById('map-price').value.trim();
var stock = document.getElementById('map-stock').value.trim();
var location = document.getElementById('map-location').value.trim();
var statusEl = document.getElementById('mapping-status');
if (!partNumber || !price || !stock) {
statusEl.textContent = 'Completa los campos obligatorios.';
statusEl.className = 'status-msg error';
return;
}
var body = {
part_number: partNumber,
price: price,
stock: stock,
location: location || null
};
statusEl.textContent = 'Guardando...';
statusEl.className = 'status-msg';
api('/api/inventory/mapping', {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(body)
}).then(function () {
statusEl.textContent = 'Mapeo guardado correctamente.';
statusEl.className = 'status-msg success';
toast('Mapeo guardado', 'success');
}).catch(function (err) {
statusEl.textContent = err.message || 'Error al guardar.';
statusEl.className = 'status-msg error';
});
});
// ================================================================
// TAB 2: Subir Inventario
// ================================================================
var dropZone = document.getElementById('drop-zone');
var fileInput = document.getElementById('file-input');
dropZone.addEventListener('click', function () { fileInput.click(); });
dropZone.addEventListener('dragover', function (e) {
e.preventDefault();
dropZone.classList.add('dragover');
});
dropZone.addEventListener('dragleave', function () {
dropZone.classList.remove('dragover');
});
dropZone.addEventListener('drop', function (e) {
e.preventDefault();
dropZone.classList.remove('dragover');
if (e.dataTransfer.files.length) {
selectFile(e.dataTransfer.files[0]);
}
});
fileInput.addEventListener('change', function () {
if (fileInput.files.length) {
selectFile(fileInput.files[0]);
}
});
function selectFile(file) {
var validTypes = [
'text/csv',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
];
var ext = (file.name || '').split('.').pop().toLowerCase();
if (validTypes.indexOf(file.type) === -1 && ['csv', 'xls', 'xlsx'].indexOf(ext) === -1) {
toast('Formato no soportado. Usa CSV o Excel.', 'error');
return;
}
if (file.size > 10 * 1024 * 1024) {
toast('El archivo excede 10MB.', 'error');
return;
}
selectedFile = file;
document.getElementById('selected-file-name').textContent = file.name + ' (' + (file.size / 1024).toFixed(1) + ' KB)';
document.getElementById('selected-file').style.display = 'flex';
document.getElementById('btn-upload').disabled = false;
}
document.getElementById('btn-clear-file').addEventListener('click', function () {
selectedFile = null;
fileInput.value = '';
document.getElementById('selected-file').style.display = 'none';
document.getElementById('btn-upload').disabled = true;
});
document.getElementById('btn-upload').addEventListener('click', function () {
if (!selectedFile) return;
var btn = document.getElementById('btn-upload');
var statusEl = document.getElementById('upload-status');
btn.disabled = true;
statusEl.textContent = 'Subiendo...';
statusEl.className = 'status-msg';
var fd = new FormData();
fd.append('file', selectedFile);
fetch(API + '/api/inventory/upload', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + getToken() },
body: fd
}).then(function (r) {
if (r.status === 401) {
return tryRefreshToken().then(function () {
return fetch(API + '/api/inventory/upload', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + getToken() },
body: fd
});
});
}
return r;
}).then(function (r) {
return r.json().then(function (data) {
if (!r.ok) throw new Error(data.error || 'Error al subir');
return data;
});
}).then(function (data) {
statusEl.textContent = '';
showUploadResult(data);
toast('Archivo procesado correctamente.', 'success');
loadUploadHistory();
// Clear file selection
selectedFile = null;
fileInput.value = '';
document.getElementById('selected-file').style.display = 'none';
btn.disabled = true;
}).catch(function (err) {
statusEl.textContent = err.message || 'Error al subir archivo.';
statusEl.className = 'status-msg error';
btn.disabled = false;
});
});
function showUploadResult(data) {
var el = document.getElementById('upload-result');
var imported = data.imported || data.imported_count || 0;
var errors = data.errors || data.error_count || 0;
var samples = data.error_samples || [];
var html = '<h4>Resultado de la Carga</h4>';
html += '<div class="result-stats">';
html += '<span class="result-stat ok">Importados: ' + imported + '</span>';
html += '<span class="result-stat err">Errores: ' + errors + '</span>';
html += '</div>';
if (samples.length) {
html += '<div class="error-samples">';
html += '<strong>Ejemplos de errores:</strong>';
samples.forEach(function (s) {
html += '<p>' + esc(typeof s === 'string' ? s : JSON.stringify(s)) + '</p>';
});
html += '</div>';
}
el.innerHTML = html;
el.style.display = 'block';
}
function loadUploadHistory() {
api('/api/inventory/uploads').then(function (data) {
var rows = data.uploads || data.data || data || [];
var tbody = document.getElementById('history-body');
if (!Array.isArray(rows) || rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="empty-row">Sin cargas previas</td></tr>';
return;
}
tbody.innerHTML = rows.map(function (r) {
return '<tr>'
+ '<td>' + esc(r.filename || r.archivo || '—') + '</td>'
+ '<td>' + statusBadge(r.status || r.estado) + '</td>'
+ '<td>' + (r.imported_count != null ? r.imported_count : (r.importados != null ? r.importados : '—')) + '</td>'
+ '<td>' + (r.error_count != null ? r.error_count : (r.errores != null ? r.errores : '—')) + '</td>'
+ '<td>' + fmtDate(r.created_at || r.fecha) + '</td>'
+ '</tr>';
}).join('');
}).catch(function () {
document.getElementById('history-body').innerHTML =
'<tr><td colspan="5" class="empty-row">Error al cargar historial</td></tr>';
});
}
// ================================================================
// TAB 3: Mi Inventario
// ================================================================
function loadInventory() {
var params = '?page=' + invPage;
if (invQuery) params += '&q=' + encodeURIComponent(invQuery);
api('/api/inventory/items' + params).then(function (data) {
var items = data.data || data.items || [];
var pagination = data.pagination || {};
var tbody = document.getElementById('inv-body');
if (!Array.isArray(items) || items.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="empty-row">Sin articulos en inventario</td></tr>';
renderPagination(pagination);
return;
}
tbody.innerHTML = items.map(function (it) {
return '<tr>'
+ '<td><strong>' + esc(it.part_number) + '</strong></td>'
+ '<td>' + esc(it.name || it.nombre || '—') + '</td>'
+ '<td>' + fmtPrice(it.price || it.precio) + '</td>'
+ '<td>' + (it.stock != null ? it.stock : (it.existencias != null ? it.existencias : '—')) + '</td>'
+ '<td>' + esc(it.location || it.ubicacion || '—') + '</td>'
+ '<td>' + fmtDate(it.updated_at || it.actualizado) + '</td>'
+ '</tr>';
}).join('');
renderPagination(pagination);
}).catch(function () {
document.getElementById('inv-body').innerHTML =
'<tr><td colspan="6" class="empty-row">Error al cargar inventario</td></tr>';
});
}
function renderPagination(pg) {
var container = document.getElementById('inv-pagination');
if (!pg || !pg.total_pages || pg.total_pages <= 1) {
container.innerHTML = '';
return;
}
var current = pg.page || pg.current_page || 1;
var total = pg.total_pages;
var html = '';
html += '<button ' + (current <= 1 ? 'disabled' : '') + ' data-page="' + (current - 1) + '">Anterior</button>';
var start = Math.max(1, current - 2);
var end = Math.min(total, current + 2);
if (start > 1) {
html += '<button data-page="1">1</button>';
if (start > 2) html += '<button disabled>...</button>';
}
for (var i = start; i <= end; i++) {
html += '<button data-page="' + i + '"' + (i === current ? ' class="active"' : '') + '>' + i + '</button>';
}
if (end < total) {
if (end < total - 1) html += '<button disabled>...</button>';
html += '<button data-page="' + total + '">' + total + '</button>';
}
html += '<button ' + (current >= total ? 'disabled' : '') + ' data-page="' + (current + 1) + '">Siguiente</button>';
container.innerHTML = html;
container.querySelectorAll('button[data-page]').forEach(function (btn) {
btn.addEventListener('click', function () {
invPage = parseInt(btn.getAttribute('data-page'), 10);
loadInventory();
});
});
}
// Search
document.getElementById('btn-inv-search').addEventListener('click', function () {
invQuery = document.getElementById('inv-search').value.trim();
invPage = 1;
loadInventory();
});
document.getElementById('inv-search').addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
invQuery = this.value.trim();
invPage = 1;
loadInventory();
}
});
// Clear All
document.getElementById('btn-clear-all').addEventListener('click', function () {
showConfirm(
'Limpiar Inventario',
'Se eliminaran todos los articulos de tu inventario. Esta accion no se puede deshacer.',
function () {
api('/api/inventory/items', {
method: 'DELETE',
headers: authHeaders()
}).then(function () {
toast('Inventario limpiado correctamente.', 'success');
loadInventory();
}).catch(function (err) {
toast(err.message || 'Error al limpiar inventario.', 'error');
});
}
);
});
// ================================================================
// Confirm Modal
// ================================================================
var confirmCallback = null;
function showConfirm(title, msg, onConfirm) {
document.getElementById('confirm-title').textContent = title;
document.getElementById('confirm-msg').textContent = msg;
document.getElementById('confirm-modal').classList.add('active');
confirmCallback = onConfirm;
}
document.getElementById('confirm-cancel').addEventListener('click', function () {
document.getElementById('confirm-modal').classList.remove('active');
confirmCallback = null;
});
document.getElementById('confirm-ok').addEventListener('click', function () {
document.getElementById('confirm-modal').classList.remove('active');
if (confirmCallback) {
confirmCallback();
confirmCallback = null;
}
});
document.getElementById('confirm-modal').addEventListener('click', function (e) {
if (e.target === this) {
this.classList.remove('active');
confirmCallback = null;
}
});
// ================================================================
// Init
// ================================================================
if (checkAuth()) {
loadMapping();
}
})();

660
dashboard/captura.css Normal file
View File

@@ -0,0 +1,660 @@
/* ============================================================
captura.css -- Styles for Nexus Autoparts Data Entry
============================================================ */
/* --- Tabs --- */
.captura-tabs {
display: flex;
gap: 0;
border-bottom: 2px solid var(--border);
margin-bottom: 1.5rem;
}
.captura-tab {
padding: 0.8rem 1.8rem;
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
border-bottom: 3px solid transparent;
transition: all 0.2s;
position: relative;
bottom: -2px;
}
.captura-tab:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
.captura-tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.captura-tab .tab-badge {
background: var(--accent);
color: #fff;
font-size: 0.7rem;
padding: 0.15rem 0.5rem;
border-radius: 10px;
margin-left: 0.5rem;
font-weight: 700;
}
.captura-section {
display: none;
}
.captura-section.active {
display: block;
}
/* --- Vehicle Selector (Section 1) --- */
.vehicle-filters {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
align-items: flex-end;
}
.vehicle-filters .filter-group {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.vehicle-filters label {
font-size: 0.75rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.vehicle-filters select,
.vehicle-filters input {
padding: 0.5rem 0.8rem;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-primary);
font-size: 0.9rem;
min-width: 160px;
}
.vehicle-filters select:focus,
.vehicle-filters input:focus {
outline: none;
border-color: var(--accent);
}
/* --- Vehicle List --- */
.vehicle-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 0.8rem;
max-height: 400px;
overflow-y: auto;
padding-right: 0.5rem;
}
.vehicle-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1rem;
cursor: pointer;
transition: all 0.2s;
}
.vehicle-card:hover {
border-color: var(--accent);
background: var(--bg-hover);
}
.vehicle-card .vc-brand {
font-weight: 700;
font-size: 0.95rem;
color: var(--accent);
}
.vehicle-card .vc-model {
font-size: 1.1rem;
font-weight: 600;
margin: 0.2rem 0;
}
.vehicle-card .vc-details {
font-size: 0.8rem;
color: var(--text-secondary);
}
.vehicle-card .vc-parts-count {
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--success);
}
/* --- Vehicle Header (when editing) --- */
.vehicle-header {
background: linear-gradient(135deg, var(--bg-card) 0%, var(--bg-hover) 100%);
border: 1px solid var(--accent);
border-radius: 12px;
padding: 1rem 1.5rem;
margin-bottom: 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.vehicle-header .vh-info {
display: flex;
gap: 1.5rem;
align-items: center;
flex-wrap: wrap;
}
.vehicle-header .vh-label {
font-size: 0.7rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.vehicle-header .vh-value {
font-size: 1.1rem;
font-weight: 700;
}
.vehicle-header .vh-brand { color: var(--accent); }
.vehicle-header .vh-actions {
display: flex;
gap: 0.5rem;
}
/* --- Part Groups Table --- */
.category-section {
margin-bottom: 1.5rem;
}
.category-header {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px 8px 0 0;
padding: 0.6rem 1rem;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
}
.category-header:hover {
background: var(--bg-hover);
}
.category-header h3 {
font-size: 0.9rem;
font-weight: 700;
color: var(--accent);
}
.category-header .cat-toggle {
font-size: 0.8rem;
color: var(--text-secondary);
transition: transform 0.2s;
}
.category-header.collapsed .cat-toggle {
transform: rotate(-90deg);
}
.category-body {
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 8px 8px;
}
.category-body.collapsed {
display: none;
}
.group-section {
border-bottom: 1px solid var(--border);
padding: 0.8rem 1rem;
}
.group-section:last-child {
border-bottom: none;
}
.group-name {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.5rem;
}
/* --- Part Rows --- */
.part-rows {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-bottom: 0.4rem;
}
.part-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.part-row input {
padding: 0.4rem 0.6rem;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.85rem;
}
.part-row input:focus {
outline: none;
border-color: var(--accent);
}
.part-row .pr-oem {
width: 160px;
font-family: monospace;
}
.part-row .pr-name {
flex: 1;
min-width: 150px;
}
.part-row .pr-qty {
width: 50px;
text-align: center;
}
.part-row .pr-btn {
padding: 0.3rem 0.6rem;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.8rem;
font-weight: 600;
transition: all 0.2s;
}
.part-row .pr-save {
background: var(--success);
color: #fff;
}
.part-row .pr-save:hover { background: #1ea34e; }
.part-row .pr-delete {
background: var(--danger);
color: #fff;
}
.part-row .pr-delete:hover { background: #cc3333; }
.part-row.saved {
background: rgba(34, 197, 94, 0.08);
border-radius: 6px;
padding: 0.2rem 0.4rem;
}
.part-row.saved input {
background: transparent;
border-color: var(--success);
color: var(--success);
}
.btn-add-part {
background: transparent;
border: 1px dashed var(--border);
border-radius: 6px;
padding: 0.3rem 0.8rem;
color: var(--text-secondary);
font-size: 0.8rem;
cursor: pointer;
transition: all 0.2s;
}
.btn-add-part:hover {
border-color: var(--accent);
color: var(--accent);
}
/* --- Progress Bar --- */
.progress-bar {
background: var(--bg-secondary);
border-radius: 10px;
height: 8px;
overflow: hidden;
margin: 0.5rem 0;
}
.progress-bar .progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--success));
border-radius: 10px;
transition: width 0.3s;
}
.progress-text {
font-size: 0.75rem;
color: var(--text-secondary);
}
/* --- Section 2: Intercambios --- */
.part-detail-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1rem;
margin-bottom: 1rem;
}
.part-detail-card .pdc-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.8rem;
}
.part-detail-card .pdc-oem {
font-family: monospace;
font-size: 1rem;
font-weight: 700;
color: var(--accent);
}
.part-detail-card .pdc-name {
font-size: 0.85rem;
color: var(--text-secondary);
}
.part-detail-card .pdc-group {
font-size: 0.75rem;
color: var(--text-secondary);
background: var(--bg-hover);
padding: 0.2rem 0.5rem;
border-radius: 4px;
}
.aftermarket-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.aftermarket-table th {
text-align: left;
padding: 0.5rem;
border-bottom: 1px solid var(--border);
color: var(--text-secondary);
font-size: 0.75rem;
text-transform: uppercase;
}
.aftermarket-table td {
padding: 0.4rem 0.5rem;
border-bottom: 1px solid rgba(42, 42, 58, 0.5);
}
.aftermarket-form {
display: flex;
gap: 0.5rem;
align-items: flex-end;
flex-wrap: wrap;
margin-top: 0.8rem;
padding-top: 0.8rem;
border-top: 1px dashed var(--border);
}
.aftermarket-form .af-field {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.aftermarket-form label {
font-size: 0.7rem;
color: var(--text-secondary);
text-transform: uppercase;
}
.aftermarket-form select,
.aftermarket-form input {
padding: 0.4rem 0.6rem;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.85rem;
}
.aftermarket-form select:focus,
.aftermarket-form input:focus {
outline: none;
border-color: var(--accent);
}
/* --- Section 3: Imágenes --- */
.image-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1rem;
display: flex;
gap: 1rem;
align-items: center;
margin-bottom: 0.8rem;
}
.image-card .ic-preview {
width: 100px;
height: 100px;
background: var(--bg-secondary);
border: 2px dashed var(--border);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
font-size: 0.75rem;
overflow: hidden;
flex-shrink: 0;
}
.image-card .ic-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-card .ic-info {
flex: 1;
}
.image-card .ic-oem {
font-family: monospace;
font-weight: 700;
color: var(--accent);
}
.image-card .ic-name {
font-size: 0.85rem;
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
.image-card .ic-upload {
display: flex;
gap: 0.5rem;
align-items: center;
}
.image-card .ic-upload input[type="file"] {
font-size: 0.8rem;
color: var(--text-secondary);
}
/* --- Search bar --- */
.captura-search {
display: flex;
gap: 0.8rem;
margin-bottom: 1rem;
align-items: center;
}
.captura-search input {
padding: 0.5rem 0.8rem;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-primary);
font-size: 0.9rem;
flex: 1;
max-width: 400px;
}
.captura-search input:focus {
outline: none;
border-color: var(--accent);
}
/* --- Pagination --- */
.captura-pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
padding: 0.5rem;
}
.captura-pagination button {
padding: 0.4rem 0.8rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
cursor: pointer;
font-size: 0.85rem;
}
.captura-pagination button:hover {
border-color: var(--accent);
}
.captura-pagination button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.captura-pagination .page-info {
font-size: 0.8rem;
color: var(--text-secondary);
}
/* --- Empty state --- */
.empty-state {
text-align: center;
padding: 3rem;
color: var(--text-secondary);
}
.empty-state .es-icon {
font-size: 2.5rem;
margin-bottom: 0.5rem;
}
.empty-state .es-text {
font-size: 0.9rem;
}
/* --- Toast notifications --- */
.toast {
position: fixed;
bottom: 2rem;
right: 2rem;
padding: 0.8rem 1.5rem;
border-radius: 10px;
color: #fff;
font-weight: 600;
font-size: 0.9rem;
z-index: 9999;
animation: toastIn 0.3s ease;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
}
.toast.success { background: var(--success); }
.toast.error { background: var(--danger); }
@keyframes toastIn {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
/* --- Loading spinner --- */
.loading {
display: flex;
justify-content: center;
padding: 2rem;
}
.spinner {
width: 30px;
height: 30px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* --- Layout --- */
.captura-container {
max-width: 1200px;
margin: 0 auto;
padding: 5rem 2rem 2rem;
}
/* --- Status tabs for vehicles --- */
.status-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.status-tab {
padding: 0.4rem 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 20px;
color: var(--text-secondary);
font-size: 0.8rem;
cursor: pointer;
transition: all 0.2s;
}
.status-tab:hover { border-color: var(--accent); }
.status-tab.active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}

99
dashboard/captura.html Normal file
View File

@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Captura de Datos — NEXUS AUTOPARTS</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Orbitron:wght@700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/shared.css">
<link rel="stylesheet" href="/captura.css">
</head>
<body>
<div id="shared-nav"></div>
<div class="captura-container">
<!-- Main Tabs -->
<div class="captura-tabs">
<button class="captura-tab active" data-tab="oem">Partes OEM</button>
<button class="captura-tab" data-tab="aftermarket">Intercambios</button>
<button class="captura-tab" data-tab="images">Imagenes</button>
</div>
<!-- ============================================ -->
<!-- SECTION 1: OEM Parts Entry -->
<!-- ============================================ -->
<div id="section-oem" class="captura-section active">
<!-- Vehicle selection view -->
<div id="oem-vehicle-select">
<div class="status-tabs">
<button class="status-tab active" data-status="pending">Pendientes</button>
<button class="status-tab" data-status="in_progress">En progreso</button>
</div>
<div class="vehicle-filters">
<div class="filter-group">
<label>Marca</label>
<select id="oem-brand-filter">
<option value="">Todas</option>
</select>
</div>
<div class="filter-group">
<label>Modelo</label>
<input id="oem-model-filter" type="text" placeholder="Buscar modelo...">
</div>
</div>
<div id="oem-vehicle-list" class="vehicle-list">
<div class="loading"><div class="spinner"></div></div>
</div>
<div id="oem-vehicle-pagination" class="captura-pagination"></div>
</div>
<!-- Part entry view (hidden until vehicle selected) -->
<div id="oem-part-entry" style="display: none;">
<div id="oem-vehicle-header" class="vehicle-header"></div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<div>
<div class="progress-bar" style="width: 200px;">
<div id="oem-progress-fill" class="progress-fill" style="width: 0%"></div>
</div>
<span id="oem-progress-text" class="progress-text">0 partes registradas</span>
</div>
</div>
<div id="oem-groups-container"></div>
</div>
</div>
<!-- ============================================ -->
<!-- SECTION 2: Aftermarket / Interchange Entry -->
<!-- ============================================ -->
<div id="section-aftermarket" class="captura-section">
<div class="captura-search">
<input id="aftermarket-search" type="text" placeholder="Buscar por # OEM o nombre...">
<button class="btn btn-primary" onclick="loadPartsWithoutAftermarket()">Buscar</button>
</div>
<div id="aftermarket-list"></div>
<div id="aftermarket-pagination" class="captura-pagination"></div>
</div>
<!-- ============================================ -->
<!-- SECTION 3: Image Upload -->
<!-- ============================================ -->
<div id="section-images" class="captura-section">
<div class="captura-search">
<input id="image-search" type="text" placeholder="Buscar por # OEM o nombre...">
<button class="btn btn-primary" onclick="loadPartsWithoutImage()">Buscar</button>
</div>
<div id="image-list"></div>
<div id="image-pagination" class="captura-pagination"></div>
</div>
</div>
<script src="/nav.js"></script>
<script src="/captura.js"></script>
</body>
</html>

707
dashboard/captura.js Normal file
View File

@@ -0,0 +1,707 @@
/**
* captura.js — Data entry logic for Nexus Autoparts
* 3 sections: OEM Parts, Aftermarket/Interchange, Images
*/
(function () {
'use strict';
var API = '';
var currentMye = null; // selected vehicle MYE id
var currentVehicle = null; // vehicle info object
var vehicleParts = []; // existing parts for current vehicle
var manufacturers = []; // cached manufacturer list
var vehicleStatus = 'pending';
var vehiclePage = 1;
// ================================================================
// Utility
// ================================================================
function toast(msg, type) {
var el = document.createElement('div');
el.className = 'toast ' + (type || 'success');
el.textContent = msg;
document.body.appendChild(el);
setTimeout(function () { el.remove(); }, 3000);
}
function api(path, opts) {
opts = opts || {};
return fetch(API + path, opts).then(function (r) {
if (!r.ok) return r.json().then(function (d) { throw new Error(d.error || 'Error'); });
return r.json();
});
}
function esc(s) {
if (!s) return '';
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// ================================================================
// Tab Switching
// ================================================================
document.querySelectorAll('.captura-tab').forEach(function (tab) {
tab.addEventListener('click', function () {
document.querySelectorAll('.captura-tab').forEach(function (t) { t.classList.remove('active'); });
document.querySelectorAll('.captura-section').forEach(function (s) { s.classList.remove('active'); });
tab.classList.add('active');
var target = tab.getAttribute('data-tab');
document.getElementById('section-' + target).classList.add('active');
if (target === 'aftermarket') loadPartsWithoutAftermarket();
if (target === 'images') loadPartsWithoutImage();
});
});
// ================================================================
// SECTION 1: OEM Parts
// ================================================================
// --- Status tabs ---
document.querySelectorAll('.status-tab').forEach(function (tab) {
tab.addEventListener('click', function () {
document.querySelectorAll('.status-tab').forEach(function (t) { t.classList.remove('active'); });
tab.classList.add('active');
vehicleStatus = tab.getAttribute('data-status');
vehiclePage = 1;
loadVehicles();
});
});
// --- Brand filter ---
function loadBrands() {
api('/api/brands').then(function (brands) {
var sel = document.getElementById('oem-brand-filter');
brands.forEach(function (b) {
var opt = document.createElement('option');
opt.value = b;
opt.textContent = b;
sel.appendChild(opt);
});
});
}
document.getElementById('oem-brand-filter').addEventListener('change', function () {
vehiclePage = 1;
loadVehicles();
});
var modelTimer = null;
document.getElementById('oem-model-filter').addEventListener('input', function () {
clearTimeout(modelTimer);
modelTimer = setTimeout(function () {
vehiclePage = 1;
loadVehicles();
}, 400);
});
// --- Load vehicles ---
function loadVehicles() {
var brand = document.getElementById('oem-brand-filter').value;
var model = document.getElementById('oem-model-filter').value;
var list = document.getElementById('oem-vehicle-list');
list.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
var endpoint = vehicleStatus === 'pending'
? '/api/captura/vehicles/pending'
: '/api/captura/vehicles/in-progress';
var params = '?page=' + vehiclePage + '&per_page=30';
if (brand) params += '&brand=' + encodeURIComponent(brand);
if (model) params += '&model=' + encodeURIComponent(model);
api(endpoint + params).then(function (res) {
var data = res.data || [];
if (data.length === 0) {
list.innerHTML = '<div class="empty-state"><div class="es-icon">&#128203;</div><div class="es-text">No hay vehiculos ' +
(vehicleStatus === 'pending' ? 'pendientes' : 'en progreso') + '</div></div>';
document.getElementById('oem-vehicle-pagination').innerHTML = '';
return;
}
list.innerHTML = data.map(function (v) {
return '<div class="vehicle-card" data-mye="' + v.id_mye + '">' +
'<div class="vc-brand">' + esc(v.brand) + '</div>' +
'<div class="vc-model">' + esc(v.model) + '</div>' +
'<div class="vc-details">' + v.year + ' &middot; ' + esc(v.engine) +
(v.trim_level ? ' &middot; ' + esc(v.trim_level) : '') + '</div>' +
(v.parts_count ? '<div class="vc-parts-count">' + v.parts_count + ' partes registradas</div>' : '') +
'</div>';
}).join('');
// Click handler for vehicle cards
list.querySelectorAll('.vehicle-card').forEach(function (card) {
card.addEventListener('click', function () {
selectVehicle(parseInt(card.getAttribute('data-mye')));
});
});
// Pagination
renderPagination('oem-vehicle-pagination', res.pagination, function (p) {
vehiclePage = p;
loadVehicles();
});
});
}
function renderPagination(containerId, pag, onPage) {
var c = document.getElementById(containerId);
if (!pag || pag.total_pages <= 1) { c.innerHTML = ''; return; }
c.innerHTML = '<button ' + (pag.page <= 1 ? 'disabled' : '') + ' data-p="' + (pag.page - 1) + '">&laquo; Anterior</button>' +
'<span class="page-info">Pag ' + pag.page + ' de ' + pag.total_pages + ' (' + pag.total + ' total)</span>' +
'<button ' + (pag.page >= pag.total_pages ? 'disabled' : '') + ' data-p="' + (pag.page + 1) + '">Siguiente &raquo;</button>';
c.querySelectorAll('button').forEach(function (btn) {
btn.addEventListener('click', function () {
onPage(parseInt(btn.getAttribute('data-p')));
});
});
}
// --- Select vehicle and show part entry ---
function selectVehicle(myeId) {
currentMye = myeId;
document.getElementById('oem-vehicle-select').style.display = 'none';
document.getElementById('oem-part-entry').style.display = 'block';
// Mark as in_progress
api('/api/captura/vehicles/' + myeId + '/status', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'in_progress' })
});
loadVehicleParts(myeId);
}
function loadVehicleParts(myeId) {
api('/api/captura/vehicles/' + myeId + '/parts').then(function (res) {
currentVehicle = res.vehicle;
vehicleParts = res.parts || [];
// Render vehicle header
var hdr = document.getElementById('oem-vehicle-header');
hdr.innerHTML = '<div class="vh-info">' +
'<div><div class="vh-label">Marca</div><div class="vh-value vh-brand">' + esc(currentVehicle.brand) + '</div></div>' +
'<div><div class="vh-label">Modelo</div><div class="vh-value">' + esc(currentVehicle.model) + '</div></div>' +
'<div><div class="vh-label">Ano</div><div class="vh-value">' + currentVehicle.year + '</div></div>' +
'<div><div class="vh-label">Motor</div><div class="vh-value">' + esc(currentVehicle.engine) + '</div></div>' +
(currentVehicle.trim_level ? '<div><div class="vh-label">Trim</div><div class="vh-value">' + esc(currentVehicle.trim_level) + '</div></div>' : '') +
'</div>' +
'<div class="vh-actions">' +
'<button class="btn btn-secondary" id="btn-back-vehicles">&#9664; Volver</button>' +
'<button class="btn btn-primary" id="btn-complete-vehicle">Terminado &#10003;</button>' +
'</div>';
document.getElementById('btn-back-vehicles').addEventListener('click', backToVehicles);
document.getElementById('btn-complete-vehicle').addEventListener('click', completeVehicle);
// Build groups by category
renderGroups(res.groups, vehicleParts);
updateProgress();
});
}
function backToVehicles() {
document.getElementById('oem-vehicle-select').style.display = 'block';
document.getElementById('oem-part-entry').style.display = 'none';
currentMye = null;
loadVehicles();
}
function completeVehicle() {
if (vehicleParts.length === 0) {
toast('Registra al menos una parte antes de marcar como terminado', 'error');
return;
}
api('/api/captura/vehicles/' + currentMye + '/status', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'completed' })
}).then(function () {
toast('Vehiculo completado');
backToVehicles();
});
}
// --- Render groups/categories ---
function renderGroups(groups, parts) {
var container = document.getElementById('oem-groups-container');
// Group by category
var categories = {};
groups.forEach(function (g) {
if (!categories[g.category]) {
categories[g.category] = { id: g.id_part_category, groups: [] };
}
categories[g.category].groups.push(g);
});
var html = '';
Object.keys(categories).forEach(function (catName) {
var cat = categories[catName];
var catParts = parts.filter(function (p) {
return cat.groups.some(function (g) { return g.id_part_group === p.group_id; });
});
html += '<div class="category-section">' +
'<div class="category-header" data-cat="' + cat.id + '">' +
'<h3>' + esc(catName) + ' (' + catParts.length + ')</h3>' +
'<span class="cat-toggle">&#9660;</span></div>' +
'<div class="category-body" data-cat-body="' + cat.id + '">';
cat.groups.forEach(function (g) {
var groupParts = parts.filter(function (p) { return p.group_id === g.id_part_group; });
html += '<div class="group-section" data-group="' + g.id_part_group + '">' +
'<div class="group-name">' + esc(g.group_name) + '</div>' +
'<div class="part-rows" data-group-parts="' + g.id_part_group + '">';
groupParts.forEach(function (p) {
html += savedPartRow(p);
});
html += '</div>' +
'<button class="btn-add-part" data-group-id="' + g.id_part_group + '">+ Agregar pieza</button>' +
'</div>';
});
html += '</div></div>';
});
container.innerHTML = html;
// Category toggle
container.querySelectorAll('.category-header').forEach(function (ch) {
ch.addEventListener('click', function () {
var catId = ch.getAttribute('data-cat');
var body = container.querySelector('[data-cat-body="' + catId + '"]');
ch.classList.toggle('collapsed');
body.classList.toggle('collapsed');
});
});
// Add part buttons
container.querySelectorAll('.btn-add-part').forEach(function (btn) {
btn.addEventListener('click', function () {
addPartRow(parseInt(btn.getAttribute('data-group-id')), btn);
});
});
}
function savedPartRow(p) {
return '<div class="part-row saved" data-fitment-id="' + p.id_vehicle_part + '">' +
'<input class="pr-oem" value="' + esc(p.oem_part_number) + '" readonly>' +
'<input class="pr-name" value="' + esc(p.name_part || '') + '" readonly>' +
'<input class="pr-qty" value="' + (p.quantity_required || 1) + '" readonly>' +
'<button class="pr-btn pr-delete" title="Eliminar">&#10005;</button>' +
'</div>';
}
function addPartRow(groupId, addBtn) {
var rowsContainer = document.querySelector('[data-group-parts="' + groupId + '"]');
var row = document.createElement('div');
row.className = 'part-row';
row.innerHTML = '<input class="pr-oem" placeholder="# OEM" data-group="' + groupId + '">' +
'<input class="pr-name" placeholder="Nombre pieza">' +
'<input class="pr-qty" value="1" type="number" min="1">' +
'<button class="pr-btn pr-save" title="Guardar">&#10003;</button>' +
'<button class="pr-btn pr-delete" title="Quitar">&#10005;</button>';
rowsContainer.appendChild(row);
// Focus OEM field
row.querySelector('.pr-oem').focus();
// OEM blur: check if exists
row.querySelector('.pr-oem').addEventListener('blur', function () {
var oem = this.value.trim();
if (!oem) return;
api('/api/captura/parts/check-oem?oem=' + encodeURIComponent(oem)).then(function (res) {
if (res.exists) {
row.querySelector('.pr-name').value = res.part.name_part || '';
row.querySelector('.pr-name').style.borderColor = 'var(--success)';
row.dataset.existingPartId = res.part.id_part;
}
});
});
// Save
row.querySelector('.pr-save').addEventListener('click', function () {
savePart(row, groupId);
});
// Delete (unsaved)
row.querySelector('.pr-delete').addEventListener('click', function () {
row.remove();
});
}
function savePart(row, groupId) {
var oem = row.querySelector('.pr-oem').value.trim();
var name = row.querySelector('.pr-name').value.trim();
var qty = parseInt(row.querySelector('.pr-qty').value) || 1;
if (!oem) {
toast('Ingresa el numero OEM', 'error');
row.querySelector('.pr-oem').focus();
return;
}
var saveBtn = row.querySelector('.pr-save');
saveBtn.disabled = true;
saveBtn.textContent = '...';
// Check if part already exists
var existingId = row.dataset.existingPartId;
function createFitment(partId) {
api('/api/admin/fitment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model_year_engine_id: currentMye,
part_id: partId,
quantity_required: qty
})
}).then(function (res) {
// Replace row with saved version
var newPart = {
id_vehicle_part: res.id,
part_id: partId,
oem_part_number: oem,
name_part: name,
quantity_required: qty,
group_id: groupId
};
vehicleParts.push(newPart);
row.outerHTML = savedPartRow(newPart);
updateProgress();
toast('Parte guardada: ' + oem);
// Re-attach delete handlers
attachDeleteHandlers();
}).catch(function (err) {
toast(err.message, 'error');
saveBtn.disabled = false;
saveBtn.textContent = '\u2713';
});
}
if (existingId) {
createFitment(parseInt(existingId));
} else {
// Create part first
api('/api/admin/parts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
oem_part_number: oem,
name: name || oem,
group_id: groupId
})
}).then(function (res) {
createFitment(res.id);
}).catch(function (err) {
toast(err.message, 'error');
saveBtn.disabled = false;
saveBtn.textContent = '\u2713';
});
}
}
function attachDeleteHandlers() {
document.querySelectorAll('.part-row.saved .pr-delete').forEach(function (btn) {
btn.onclick = function () {
var row = btn.closest('.part-row');
var fitmentId = row.getAttribute('data-fitment-id');
if (!fitmentId) { row.remove(); return; }
api('/api/admin/fitment/' + fitmentId, { method: 'DELETE' }).then(function () {
vehicleParts = vehicleParts.filter(function (p) {
return p.id_vehicle_part !== parseInt(fitmentId);
});
row.remove();
updateProgress();
toast('Parte eliminada');
}).catch(function (err) {
toast(err.message, 'error');
});
};
});
}
function updateProgress() {
var count = vehicleParts.length;
var totalGroups = 63;
var pct = Math.min(100, Math.round((count / totalGroups) * 100));
document.getElementById('oem-progress-fill').style.width = pct + '%';
document.getElementById('oem-progress-text').textContent = count + ' partes registradas';
// Update category counts
document.querySelectorAll('.category-header h3').forEach(function (h3) {
var catSection = h3.closest('.category-section');
var rows = catSection.querySelectorAll('.part-row.saved');
var catName = h3.textContent.replace(/\s*\(\d+\)$/, '');
h3.textContent = catName + ' (' + rows.length + ')';
});
}
// ================================================================
// SECTION 2: Aftermarket / Interchange
// ================================================================
var aftermarketPage = 1;
function loadPartsWithoutAftermarket(page) {
page = page || 1;
aftermarketPage = page;
var search = document.getElementById('aftermarket-search').value;
var list = document.getElementById('aftermarket-list');
list.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
var params = '?page=' + page + '&per_page=20';
if (search) params += '&search=' + encodeURIComponent(search);
api('/api/captura/parts/without-aftermarket' + params).then(function (res) {
var data = res.data || [];
if (data.length === 0) {
list.innerHTML = '<div class="empty-state"><div class="es-icon">&#9989;</div><div class="es-text">No hay piezas sin intercambios</div></div>';
document.getElementById('aftermarket-pagination').innerHTML = '';
return;
}
list.innerHTML = data.map(function (p) {
return '<div class="part-detail-card" data-part-id="' + p.id_part + '">' +
'<div class="pdc-header">' +
'<div><span class="pdc-oem">' + esc(p.oem_part_number) + '</span>' +
' <span class="pdc-name">' + esc(p.name_part) + '</span></div>' +
'<span class="pdc-group">' + esc(p.category) + ' &rsaquo; ' + esc(p.group_name) + '</span></div>' +
'<div class="aftermarket-existing" data-af-list="' + p.id_part + '"></div>' +
'<div class="aftermarket-form" data-af-form="' + p.id_part + '">' +
'<div class="af-field"><label>Fabricante</label>' +
'<select class="af-manufacturer">' + manufacturerOptions() + '</select></div>' +
'<div class="af-field"><label># Aftermarket</label>' +
'<input class="af-partnum" placeholder="Ej: MK1234"></div>' +
'<div class="af-field"><label>Nombre</label>' +
'<input class="af-name" placeholder="Nombre pieza"></div>' +
'<div class="af-field"><label>Calidad</label>' +
'<select class="af-quality">' +
'<option value="standard">Standard</option>' +
'<option value="economy">Economy</option>' +
'<option value="oem">OEM</option>' +
'<option value="premium">Premium</option></select></div>' +
'<div class="af-field"><label>Precio USD</label>' +
'<input class="af-price" type="number" step="0.01" placeholder="0.00" style="width:80px"></div>' +
'<div class="af-field"><label>Garantia (meses)</label>' +
'<input class="af-warranty" type="number" placeholder="12" style="width:70px"></div>' +
'<button class="btn btn-primary af-save-btn" style="padding:0.4rem 1rem">+ Agregar</button>' +
'</div></div>';
}).join('');
// Load existing aftermarket for each part
data.forEach(function (p) {
loadPartAftermarket(p.id_part);
});
// Save handlers
list.querySelectorAll('.af-save-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
var card = btn.closest('.part-detail-card');
saveAftermarket(card);
});
});
renderPagination('aftermarket-pagination', res.pagination, function (p) {
loadPartsWithoutAftermarket(p);
});
});
}
function manufacturerOptions() {
return manufacturers.map(function (m) {
return '<option value="' + m.id + '">' + esc(m.name) + '</option>';
}).join('');
}
function loadPartAftermarket(partId) {
api('/api/captura/parts/' + partId + '/aftermarket').then(function (items) {
var container = document.querySelector('[data-af-list="' + partId + '"]');
if (items.length === 0) {
container.innerHTML = '<p style="font-size:0.8rem;color:var(--text-secondary);margin-bottom:0.5rem">Sin intercambios registrados</p>';
return;
}
var html = '<table class="aftermarket-table"><thead><tr>' +
'<th>Fabricante</th><th># Parte</th><th>Nombre</th><th>Calidad</th><th>Precio</th><th>Garantia</th></tr></thead><tbody>';
items.forEach(function (a) {
html += '<tr><td>' + esc(a.manufacturer) + '</td><td>' + esc(a.part_number) +
'</td><td>' + esc(a.name || '') + '</td><td>' + esc(a.quality || '') +
'</td><td>' + (a.price_usd ? '$' + a.price_usd : '') +
'</td><td>' + (a.warranty_months || '') + '</td></tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
});
}
function saveAftermarket(card) {
var partId = card.getAttribute('data-part-id');
var manufacturer = card.querySelector('.af-manufacturer').value;
var partNumber = card.querySelector('.af-partnum').value.trim();
var name = card.querySelector('.af-name').value.trim();
var quality = card.querySelector('.af-quality').value;
var price = card.querySelector('.af-price').value;
var warranty = card.querySelector('.af-warranty').value;
if (!partNumber) {
toast('Ingresa el numero de parte aftermarket', 'error');
return;
}
api('/api/admin/aftermarket', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
oem_part_id: parseInt(partId),
manufacturer_id: parseInt(manufacturer),
part_number: partNumber,
name: name,
quality_tier: quality,
price_usd: price ? parseFloat(price) : null,
warranty_months: warranty ? parseInt(warranty) : null
})
}).then(function () {
toast('Intercambio guardado: ' + partNumber);
// Clear form
card.querySelector('.af-partnum').value = '';
card.querySelector('.af-name').value = '';
card.querySelector('.af-price').value = '';
card.querySelector('.af-warranty').value = '';
// Reload aftermarket list
loadPartAftermarket(parseInt(partId));
}).catch(function (err) {
toast(err.message, 'error');
});
}
// ================================================================
// SECTION 3: Images
// ================================================================
var imagePage = 1;
function loadPartsWithoutImage(page) {
page = page || 1;
imagePage = page;
var search = document.getElementById('image-search').value;
var list = document.getElementById('image-list');
list.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
var params = '?page=' + page + '&per_page=20';
if (search) params += '&search=' + encodeURIComponent(search);
api('/api/captura/parts/without-image' + params).then(function (res) {
var data = res.data || [];
if (data.length === 0) {
list.innerHTML = '<div class="empty-state"><div class="es-icon">&#128247;</div><div class="es-text">No hay piezas sin imagen</div></div>';
document.getElementById('image-pagination').innerHTML = '';
return;
}
list.innerHTML = data.map(function (p) {
return '<div class="image-card" data-part-id="' + p.id_part + '">' +
'<div class="ic-preview"><span>Sin imagen</span></div>' +
'<div class="ic-info">' +
'<div class="ic-oem">' + esc(p.oem_part_number) + '</div>' +
'<div class="ic-name">' + esc(p.name_part) + ' &middot; ' + esc(p.group_name) + '</div>' +
'<div class="ic-upload">' +
'<input type="file" accept="image/jpeg,image/png,image/webp" class="ic-file-input">' +
'<button class="btn btn-primary ic-upload-btn" style="padding:0.3rem 0.8rem;font-size:0.8rem" disabled>Subir</button>' +
'</div></div></div>';
}).join('');
// File input change → enable upload button and show preview
list.querySelectorAll('.ic-file-input').forEach(function (input) {
input.addEventListener('change', function () {
var card = input.closest('.image-card');
var btn = card.querySelector('.ic-upload-btn');
var preview = card.querySelector('.ic-preview');
if (input.files && input.files[0]) {
btn.disabled = false;
// Show preview
var reader = new FileReader();
reader.onload = function (e) {
preview.innerHTML = '<img src="' + e.target.result + '">';
};
reader.readAsDataURL(input.files[0]);
}
});
});
// Upload button
list.querySelectorAll('.ic-upload-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
var card = btn.closest('.image-card');
uploadImage(card);
});
});
renderPagination('image-pagination', res.pagination, function (p) {
loadPartsWithoutImage(p);
});
});
}
function uploadImage(card) {
var partId = card.getAttribute('data-part-id');
var fileInput = card.querySelector('.ic-file-input');
var btn = card.querySelector('.ic-upload-btn');
if (!fileInput.files || !fileInput.files[0]) return;
btn.disabled = true;
btn.textContent = 'Subiendo...';
var formData = new FormData();
formData.append('image', fileInput.files[0]);
fetch(API + '/api/captura/parts/' + partId + '/image', {
method: 'POST',
body: formData
}).then(function (r) { return r.json(); })
.then(function (res) {
if (res.error) throw new Error(res.error);
toast('Imagen subida correctamente');
// Remove card from list
card.style.opacity = '0.3';
setTimeout(function () { card.remove(); }, 500);
}).catch(function (err) {
toast(err.message, 'error');
btn.disabled = false;
btn.textContent = 'Subir';
});
}
// ================================================================
// Init
// ================================================================
function init() {
loadBrands();
loadVehicles();
// Pre-load manufacturers for Section 2
api('/api/captura/manufacturers').then(function (data) {
manufacturers = data;
});
}
// Make functions globally accessible for inline onclick handlers
window.loadPartsWithoutAftermarket = loadPartsWithoutAftermarket;
window.loadPartsWithoutImage = loadPartsWithoutImage;
init();
})();

282
dashboard/cuentas.css Normal file
View File

@@ -0,0 +1,282 @@
/* ============================================================
cuentas.css -- Accounts receivable styles
============================================================ */
.cuentas-container {
max-width: 1200px;
margin: 0 auto;
padding: 5rem 2rem 2rem;
}
/* --- Customer list --- */
.cuentas-search {
display: flex;
gap: 0.8rem;
margin-bottom: 1rem;
}
.cuentas-search input {
flex: 1;
max-width: 400px;
padding: 0.5rem 0.8rem;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-primary);
font-size: 0.9rem;
}
.cuentas-search input:focus {
outline: none;
border-color: var(--accent);
}
.customer-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 0.8rem;
margin-bottom: 1.5rem;
}
.customer-card-item {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1rem;
cursor: pointer;
transition: all 0.2s;
}
.customer-card-item:hover {
border-color: var(--accent);
background: var(--bg-hover);
}
.cci-name {
font-weight: 700;
font-size: 1rem;
margin-bottom: 0.2rem;
}
.cci-rfc {
font-family: monospace;
font-size: 0.8rem;
color: var(--text-secondary);
}
.cci-balance-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.5rem;
}
.cci-balance {
font-size: 1.1rem;
font-weight: 700;
}
.cci-balance.positive { color: var(--danger); }
.cci-balance.zero { color: var(--success); }
.cci-limit {
font-size: 0.75rem;
color: var(--text-secondary);
}
/* --- Customer detail view --- */
.detail-view {
display: none;
}
.detail-header {
background: linear-gradient(135deg, var(--bg-card) 0%, var(--bg-hover) 100%);
border: 1px solid var(--accent);
border-radius: 12px;
padding: 1.2rem 1.5rem;
margin-bottom: 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.dh-info {
display: flex;
gap: 2rem;
flex-wrap: wrap;
}
.dh-field .dh-label {
font-size: 0.7rem;
color: var(--text-secondary);
text-transform: uppercase;
}
.dh-field .dh-value {
font-size: 1rem;
font-weight: 600;
}
.dh-field .dh-value.accent { color: var(--accent); }
.dh-field .dh-value.danger { color: var(--danger); }
.dh-field .dh-value.success { color: var(--success); }
/* --- Two-column layout for invoices/payments --- */
.detail-columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
@media (max-width: 768px) {
.detail-columns { grid-template-columns: 1fr; }
}
.detail-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
}
.detail-card h3 {
padding: 0.8rem 1rem;
border-bottom: 1px solid var(--border);
font-size: 0.9rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.detail-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.detail-table th {
text-align: left;
padding: 0.5rem 0.6rem;
border-bottom: 1px solid var(--border);
color: var(--text-secondary);
font-size: 0.75rem;
text-transform: uppercase;
}
.detail-table td {
padding: 0.4rem 0.6rem;
border-bottom: 1px solid rgba(42, 42, 58, 0.5);
}
.status-badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
}
.status-badge.pending { background: rgba(245, 158, 11, 0.15); color: var(--warning); }
.status-badge.partial { background: rgba(59, 130, 246, 0.15); color: var(--info); }
.status-badge.paid { background: rgba(34, 197, 94, 0.15); color: var(--success); }
.status-badge.cancelled { background: rgba(255, 68, 68, 0.15); color: var(--danger); }
/* --- Payment form --- */
.payment-form {
padding: 1rem;
border-top: 1px solid var(--border);
}
.payment-form h4 {
font-size: 0.85rem;
color: var(--accent);
margin-bottom: 0.8rem;
}
.pf-row {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.pf-field {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.pf-field label {
font-size: 0.7rem;
color: var(--text-secondary);
text-transform: uppercase;
}
.pf-field input,
.pf-field select {
padding: 0.4rem 0.6rem;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.85rem;
}
.pf-field input:focus,
.pf-field select:focus {
outline: none;
border-color: var(--accent);
}
/* --- Toast --- */
.toast {
position: fixed;
bottom: 2rem;
right: 2rem;
padding: 0.8rem 1.5rem;
border-radius: 10px;
color: #fff;
font-weight: 600;
font-size: 0.9rem;
z-index: 9999;
animation: toastIn 0.3s ease;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
}
.toast.success { background: var(--success); }
.toast.error { background: var(--danger); }
@keyframes toastIn {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
/* --- Pagination --- */
.cuentas-pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
}
.cuentas-pagination button {
padding: 0.4rem 0.8rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
cursor: pointer;
font-size: 0.85rem;
}
.cuentas-pagination button:hover { border-color: var(--accent); }
.cuentas-pagination button:disabled { opacity: 0.4; cursor: not-allowed; }
.cuentas-pagination .page-info { font-size: 0.8rem; color: var(--text-secondary); }
.empty-state {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
font-size: 0.9rem;
}

102
dashboard/cuentas.html Normal file
View File

@@ -0,0 +1,102 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cuentas por Cobrar — NEXUS AUTOPARTS</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Orbitron:wght@700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/shared.css">
<link rel="stylesheet" href="/cuentas.css">
</head>
<body>
<div id="shared-nav"></div>
<div class="cuentas-container">
<!-- Customer List View -->
<div id="list-view">
<div class="cuentas-search">
<input id="customer-search" type="text" placeholder="Buscar cliente por nombre o RFC...">
</div>
<div id="customer-grid" class="customer-grid"></div>
<div id="customer-pagination" class="cuentas-pagination"></div>
</div>
<!-- Customer Detail View -->
<div id="detail-view" class="detail-view">
<div class="detail-header">
<div class="dh-info">
<div class="dh-field"><div class="dh-label">Cliente</div><div class="dh-value accent" id="dh-name"></div></div>
<div class="dh-field"><div class="dh-label">RFC</div><div class="dh-value" id="dh-rfc"></div></div>
<div class="dh-field"><div class="dh-label">Saldo</div><div class="dh-value" id="dh-balance"></div></div>
<div class="dh-field"><div class="dh-label">Limite</div><div class="dh-value" id="dh-limit"></div></div>
<div class="dh-field"><div class="dh-label">Plazo</div><div class="dh-value" id="dh-terms"></div></div>
</div>
<button class="btn btn-secondary" id="btn-back-list">&laquo; Volver</button>
</div>
<div class="detail-columns">
<!-- Invoices -->
<div class="detail-card">
<h3>Facturas</h3>
<table class="detail-table">
<thead>
<tr><th>Folio</th><th>Fecha</th><th>Total</th><th>Pagado</th><th>Estado</th></tr>
</thead>
<tbody id="invoice-list"></tbody>
</table>
</div>
<!-- Payments + Form -->
<div class="detail-card">
<h3>Pagos</h3>
<table class="detail-table">
<thead>
<tr><th>Fecha</th><th>Monto</th><th>Metodo</th><th>Ref</th><th>Factura</th></tr>
</thead>
<tbody id="payment-list"></tbody>
</table>
<div class="payment-form">
<h4>Registrar Pago</h4>
<div class="pf-row">
<div class="pf-field">
<label>Monto *</label>
<input id="pay-amount" type="number" step="0.01" min="0.01" placeholder="0.00" style="width:120px">
</div>
<div class="pf-field">
<label>Metodo</label>
<select id="pay-method">
<option value="efectivo">Efectivo</option>
<option value="transferencia">Transferencia</option>
<option value="cheque">Cheque</option>
<option value="tarjeta">Tarjeta</option>
</select>
</div>
<div class="pf-field">
<label>Referencia</label>
<input id="pay-reference" placeholder="# ref" style="width:120px">
</div>
<div class="pf-field">
<label>Aplicar a factura</label>
<select id="pay-invoice">
<option value="">Abono general</option>
</select>
</div>
</div>
<div class="pf-row">
<div class="pf-field" style="flex:1">
<label>Notas</label>
<input id="pay-notes" placeholder="Notas del pago" style="width:100%">
</div>
<button class="btn btn-primary" id="btn-pay" style="align-self:flex-end;padding:0.4rem 1.2rem">Registrar Pago</button>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="/nav.js"></script>
<script src="/cuentas.js"></script>
</body>
</html>

222
dashboard/cuentas.js Normal file
View File

@@ -0,0 +1,222 @@
/**
* cuentas.js — Accounts receivable logic for Nexus Autoparts
*/
(function () {
'use strict';
var API = '';
var currentCustomerId = null;
var customerPage = 1;
// ================================================================
// Utility
// ================================================================
function toast(msg, type) {
var el = document.createElement('div');
el.className = 'toast ' + (type || 'success');
el.textContent = msg;
document.body.appendChild(el);
setTimeout(function () { el.remove(); }, 3000);
}
function api(path, opts) {
opts = opts || {};
return fetch(API + path, opts).then(function (r) {
if (!r.ok) return r.json().then(function (d) { throw new Error(d.error || 'Error'); });
return r.json();
});
}
function esc(s) {
if (!s) return '';
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function fmt(n) {
return '$' + (parseFloat(n) || 0).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
function fmtDate(d) {
if (!d) return '';
var dt = new Date(d);
return dt.toLocaleDateString('es-MX', { day: '2-digit', month: 'short', year: 'numeric' });
}
// ================================================================
// Customer List
// ================================================================
var searchTimer = null;
document.getElementById('customer-search').addEventListener('input', function () {
clearTimeout(searchTimer);
searchTimer = setTimeout(function () {
customerPage = 1;
loadCustomers();
}, 400);
});
function loadCustomers() {
var search = document.getElementById('customer-search').value;
var grid = document.getElementById('customer-grid');
grid.innerHTML = '<div class="empty-state">Cargando...</div>';
var params = '?page=' + customerPage + '&per_page=30';
if (search) params += '&search=' + encodeURIComponent(search);
api('/api/pos/customers' + params).then(function (res) {
var data = res.data || [];
if (data.length === 0) {
grid.innerHTML = '<div class="empty-state">No se encontraron clientes</div>';
document.getElementById('customer-pagination').innerHTML = '';
return;
}
grid.innerHTML = data.map(function (c) {
return '<div class="customer-card-item" data-id="' + c.id_customer + '">' +
'<div class="cci-name">' + esc(c.name) + '</div>' +
'<div class="cci-rfc">' + esc(c.rfc || 'Sin RFC') + '</div>' +
'<div class="cci-balance-row">' +
'<span class="cci-balance ' + (c.balance > 0 ? 'positive' : 'zero') + '">' + fmt(c.balance) + '</span>' +
'<span class="cci-limit">Limite: ' + fmt(c.credit_limit) + '</span></div></div>';
}).join('');
grid.querySelectorAll('.customer-card-item').forEach(function (card) {
card.addEventListener('click', function () {
showCustomerDetail(parseInt(card.getAttribute('data-id')));
});
});
// Pagination
var pag = res.pagination;
var pagEl = document.getElementById('customer-pagination');
if (pag.total_pages <= 1) { pagEl.innerHTML = ''; return; }
pagEl.innerHTML = '<button ' + (pag.page <= 1 ? 'disabled' : '') + ' data-p="' + (pag.page - 1) + '">&laquo;</button>' +
'<span class="page-info">Pag ' + pag.page + '/' + pag.total_pages + '</span>' +
'<button ' + (pag.page >= pag.total_pages ? 'disabled' : '') + ' data-p="' + (pag.page + 1) + '">&raquo;</button>';
pagEl.querySelectorAll('button').forEach(function (btn) {
btn.addEventListener('click', function () {
customerPage = parseInt(btn.getAttribute('data-p'));
loadCustomers();
});
});
}).catch(function (err) {
console.error('Error loading customers:', err);
grid.innerHTML = '<div class="empty-state">Error al cargar clientes</div>';
});
}
// ================================================================
// Customer Detail
// ================================================================
function showCustomerDetail(customerId) {
currentCustomerId = customerId;
document.getElementById('list-view').style.display = 'none';
document.getElementById('detail-view').style.display = 'block';
api('/api/pos/customers/' + customerId + '/statement').then(function (res) {
var c = res.customer;
document.getElementById('dh-name').textContent = c.name;
document.getElementById('dh-rfc').textContent = c.rfc || 'Sin RFC';
var balEl = document.getElementById('dh-balance');
balEl.textContent = fmt(c.balance);
balEl.className = 'dh-value ' + (c.balance > 0 ? 'danger' : 'success');
document.getElementById('dh-limit').textContent = fmt(c.credit_limit);
document.getElementById('dh-terms').textContent = c.payment_terms + ' dias';
// Invoices
var invBody = document.getElementById('invoice-list');
if (res.invoices.length === 0) {
invBody.innerHTML = '<tr><td colspan="5" class="empty-state">Sin facturas</td></tr>';
} else {
invBody.innerHTML = res.invoices.map(function (i) {
return '<tr>' +
'<td style="font-family:monospace;font-weight:600">' + esc(i.folio) + '</td>' +
'<td>' + fmtDate(i.date_issued) + '</td>' +
'<td>' + fmt(i.total) + '</td>' +
'<td>' + fmt(i.amount_paid) + '</td>' +
'<td><span class="status-badge ' + i.status + '">' + i.status + '</span></td></tr>';
}).join('');
}
// Payments
var payBody = document.getElementById('payment-list');
if (res.payments.length === 0) {
payBody.innerHTML = '<tr><td colspan="5" class="empty-state">Sin pagos</td></tr>';
} else {
payBody.innerHTML = res.payments.map(function (p) {
return '<tr>' +
'<td>' + fmtDate(p.date_payment) + '</td>' +
'<td style="font-weight:600;color:var(--success)">' + fmt(p.amount) + '</td>' +
'<td>' + esc(p.payment_method) + '</td>' +
'<td>' + esc(p.reference || '') + '</td>' +
'<td>' + esc(p.invoice_folio || 'General') + '</td></tr>';
}).join('');
}
// Populate invoice dropdown for payment form
var invSelect = document.getElementById('pay-invoice');
invSelect.innerHTML = '<option value="">Abono general</option>';
res.invoices.filter(function (i) { return i.status !== 'paid' && i.status !== 'cancelled'; })
.forEach(function (i) {
invSelect.innerHTML += '<option value="' + i.id_invoice + '">' +
i.folio + ' — ' + fmt(i.total - i.amount_paid) + ' pendiente</option>';
});
});
}
document.getElementById('btn-back-list').addEventListener('click', function () {
document.getElementById('detail-view').style.display = 'none';
document.getElementById('list-view').style.display = 'block';
currentCustomerId = null;
loadCustomers();
});
// ================================================================
// Register Payment
// ================================================================
document.getElementById('btn-pay').addEventListener('click', function () {
var amount = parseFloat(document.getElementById('pay-amount').value);
if (!amount || amount <= 0) {
toast('Ingresa un monto valido', 'error');
return;
}
var invoiceId = document.getElementById('pay-invoice').value;
api('/api/pos/payments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
customer_id: currentCustomerId,
amount: amount,
payment_method: document.getElementById('pay-method').value,
reference: document.getElementById('pay-reference').value.trim() || null,
invoice_id: invoiceId ? parseInt(invoiceId) : null,
notes: document.getElementById('pay-notes').value.trim() || null
})
}).then(function () {
toast('Pago de ' + fmt(amount) + ' registrado');
// Clear form
document.getElementById('pay-amount').value = '';
document.getElementById('pay-reference').value = '';
document.getElementById('pay-notes').value = '';
// Refresh detail
showCustomerDetail(currentCustomerId);
}).catch(function (err) {
toast(err.message, 'error');
});
});
// ================================================================
// Init
// ================================================================
loadCustomers();
})();

View File

@@ -41,37 +41,31 @@ class VehicleDashboard {
async loadStats() { async loadStats() {
try { try {
const [brandsRes, vehiclesRes, partsRes, categoriesRes] = await Promise.all([ const [statsRes, brandsRes, categoriesRes] = await Promise.all([
fetch('/api/catalog/stats'),
fetch('/api/brands'), fetch('/api/brands'),
fetch('/api/vehicles'),
fetch('/api/parts'),
fetch('/api/categories') fetch('/api/categories')
]); ]);
if (brandsRes.ok && vehiclesRes.ok) { if (statsRes.ok) {
const brands = await brandsRes.json(); const s = await statsRes.json();
const vehiclesData = await vehiclesRes.json(); this.stats.brands = s.brands;
const vehicles = vehiclesData.data || vehiclesData; this.stats.models = s.models;
this.stats.vehicles = s.vehicles;
// Contar modelos únicos this.stats.parts = s.parts;
const uniqueModels = new Set(vehicles.map(v => `${v.brand}-${v.model}`));
this.stats.brands = brands.length;
this.stats.models = uniqueModels.size;
this.stats.vehicles = vehiclesData.pagination ? vehiclesData.pagination.total : vehicles.length;
const fmt = n => n > 1000 ? Math.floor(n/1000) + 'K+' : n;
const brandsEl = document.getElementById('totalBrands'); const brandsEl = document.getElementById('totalBrands');
const modelsEl = document.getElementById('totalModels'); const modelsEl = document.getElementById('totalModels');
if (brandsEl) brandsEl.textContent = this.stats.brands; const partsEl = document.getElementById('totalParts');
if (modelsEl) modelsEl.textContent = this.stats.models > 1000 ? Math.floor(this.stats.models/1000) + 'K+' : this.stats.models; if (brandsEl) brandsEl.textContent = fmt(this.stats.brands);
if (modelsEl) modelsEl.textContent = fmt(this.stats.models);
if (partsEl) partsEl.textContent = fmt(this.stats.parts);
} }
if (partsRes.ok) { if (brandsRes.ok) {
const partsData = await partsRes.json(); // Still needed for brand list rendering
// Handle paginated response await brandsRes.json();
this.stats.parts = partsData.pagination ? partsData.pagination.total : (partsData.data ? partsData.data.length : partsData.length || 0);
const partsEl = document.getElementById('totalParts');
if (partsEl) partsEl.textContent = this.stats.parts;
} }
if (categoriesRes.ok) { if (categoriesRes.ok) {
@@ -1144,6 +1138,11 @@ class VehicleDashboard {
<h4 class="mb-3">${part.name_es || part.name || 'Sin nombre'}</h4> <h4 class="mb-3">${part.name_es || part.name || 'Sin nombre'}</h4>
</div> </div>
</div> </div>
${part.image_url ? `
<div style="text-align:center;margin-bottom:1rem;">
<img src="${part.image_url}" alt="${part.oem_part_number || ''}" style="max-width:100%;max-height:300px;border-radius:8px;object-fit:contain;" />
</div>
` : ''}
<div class="part-detail-row"> <div class="part-detail-row">
<span class="part-detail-label">Número OEM</span> <span class="part-detail-label">Número OEM</span>
<span class="part-detail-value"><span class="part-oem-badge">${part.oem_part_number || 'N/A'}</span></span> <span class="part-detail-value"><span class="part-oem-badge">${part.oem_part_number || 'N/A'}</span></span>

1221
dashboard/demo.html Normal file

File diff suppressed because it is too large Load Diff

211
dashboard/login.css Normal file
View File

@@ -0,0 +1,211 @@
/* ============================================================
login.css -- Login / Register page styles
============================================================ */
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: var(--bg-primary);
padding: 2rem;
}
/* --- Card --- */
.login-card {
width: 100%;
max-width: 440px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 16px;
padding: 2.5rem;
animation: fadeIn 0.4s ease;
}
/* --- Brand header --- */
.login-brand {
text-align: center;
margin-bottom: 2rem;
}
.login-brand .logo-icon {
width: 56px;
height: 56px;
background: linear-gradient(135deg, var(--accent) 0%, #ff4500 100%);
border-radius: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.6rem;
margin-bottom: 1rem;
box-shadow: 0 4px 20px var(--accent-glow);
}
.login-brand h1 {
font-family: 'Orbitron', sans-serif;
font-size: 1.5rem;
font-weight: 700;
letter-spacing: 2px;
color: var(--text-primary);
margin-bottom: 0.4rem;
}
.login-brand h1 span {
color: var(--accent);
}
.login-brand .slogan {
font-size: 0.85rem;
color: var(--text-secondary);
font-weight: 400;
}
/* --- Form panel visibility --- */
.form-panel {
display: none;
}
.form-panel.active {
display: block;
animation: fadeIn 0.3s ease;
}
/* --- Form title --- */
.form-title {
font-size: 1.15rem;
font-weight: 600;
margin-bottom: 1.5rem;
text-align: center;
color: var(--text-primary);
}
/* --- Select (dropdown) --- */
.form-select {
width: 100%;
padding: 0.75rem 1rem;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-primary);
font-size: 0.95rem;
transition: border-color 0.2s;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23a0a0b0' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 1rem center;
cursor: pointer;
}
.form-select:focus {
outline: none;
border-color: var(--accent);
}
.form-select option {
background: var(--bg-secondary);
color: var(--text-primary);
}
/* --- Submit button (full width) --- */
.btn-submit {
width: 100%;
padding: 0.85rem;
margin-top: 0.5rem;
font-size: 1rem;
}
/* --- Toggle link --- */
.toggle-link {
text-align: center;
margin-top: 1.5rem;
font-size: 0.9rem;
color: var(--text-secondary);
}
.toggle-link a {
color: var(--accent);
text-decoration: none;
font-weight: 600;
cursor: pointer;
transition: color 0.2s;
}
.toggle-link a:hover {
color: var(--accent-hover);
text-decoration: underline;
}
/* --- Alert messages --- */
.login-alert {
padding: 0.85rem 1rem;
border-radius: 8px;
margin-bottom: 1.25rem;
font-size: 0.9rem;
display: none;
align-items: center;
gap: 0.5rem;
line-height: 1.4;
}
.login-alert.show {
display: flex;
}
.login-alert.error {
background: rgba(255, 68, 68, 0.1);
border: 1px solid var(--danger);
color: var(--danger);
}
.login-alert.success {
background: rgba(0, 214, 143, 0.1);
border: 1px solid var(--success);
color: var(--success);
}
/* --- Loading spinner on button --- */
.btn-submit.loading {
pointer-events: none;
opacity: 0.7;
}
.btn-submit .spinner {
display: none;
width: 18px;
height: 18px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
.btn-submit.loading .spinner {
display: inline-block;
}
.btn-submit.loading .btn-label {
display: none;
}
/* --- Row layout for two fields side by side --- */
.form-row {
display: flex;
gap: 1rem;
}
.form-row .form-group {
flex: 1;
}
/* --- Responsive --- */
@media (max-width: 500px) {
.login-card {
padding: 1.75rem 1.5rem;
}
.form-row {
flex-direction: column;
gap: 0;
}
}

108
dashboard/login.html Normal file
View File

@@ -0,0 +1,108 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nexus Autoparts - Iniciar Sesion</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Orbitron:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/shared.css">
<link rel="stylesheet" href="/login.css">
</head>
<body class="login-page">
<div class="login-card">
<!-- Brand -->
<div class="login-brand">
<div class="logo-icon">&#9881;</div>
<h1>NEXUS <span>AUTOPARTS</span></h1>
<p class="slogan">Tu conexion directa con las partes que necesitas</p>
</div>
<!-- Alert (shared between forms) -->
<div id="alert" class="login-alert" role="alert"></div>
<!-- LOGIN FORM -->
<div id="loginPanel" class="form-panel active">
<h2 class="form-title">Iniciar Sesion</h2>
<form id="loginForm" autocomplete="on">
<div class="form-group">
<label class="form-label" for="loginEmail">Correo electronico</label>
<input class="form-input" type="email" id="loginEmail" name="email"
placeholder="tu@correo.com" required autocomplete="email">
</div>
<div class="form-group">
<label class="form-label" for="loginPassword">Contrasena</label>
<input class="form-input" type="password" id="loginPassword" name="password"
placeholder="Tu contrasena" required autocomplete="current-password">
</div>
<button type="submit" class="btn btn-primary btn-submit">
<span class="spinner"></span>
<span class="btn-label">Iniciar Sesion</span>
</button>
</form>
<p class="toggle-link">
¿No tienes cuenta? <a onclick="showPanel('register')">Registrate</a>
</p>
</div>
<!-- REGISTER FORM -->
<div id="registerPanel" class="form-panel">
<h2 class="form-title">Crear Cuenta</h2>
<form id="registerForm" autocomplete="on">
<div class="form-group">
<label class="form-label" for="regName">Nombre completo</label>
<input class="form-input" type="text" id="regName" name="name"
placeholder="Juan Perez" required autocomplete="name">
</div>
<div class="form-group">
<label class="form-label" for="regEmail">Correo electronico</label>
<input class="form-input" type="email" id="regEmail" name="email"
placeholder="tu@correo.com" required autocomplete="email">
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="regPassword">Contrasena</label>
<input class="form-input" type="password" id="regPassword" name="password"
placeholder="Min. 8 caracteres" required minlength="8"
autocomplete="new-password">
</div>
<div class="form-group">
<label class="form-label" for="regConfirm">Confirmar contrasena</label>
<input class="form-input" type="password" id="regConfirm" name="confirm"
placeholder="Repetir contrasena" required minlength="8"
autocomplete="new-password">
</div>
</div>
<div class="form-group">
<label class="form-label" for="regBusiness">Nombre del negocio</label>
<input class="form-input" type="text" id="regBusiness" name="business_name"
placeholder="Taller / Refaccionaria" required autocomplete="organization">
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="regPhone">Telefono</label>
<input class="form-input" type="tel" id="regPhone" name="phone"
placeholder="(555) 123-4567" required autocomplete="tel">
</div>
<div class="form-group">
<label class="form-label" for="regRole">Tipo de cuenta</label>
<select class="form-select" id="regRole" name="role" required>
<option value="TALLER">Taller</option>
<option value="BODEGA">Bodega</option>
</select>
</div>
</div>
<button type="submit" class="btn btn-primary btn-submit">
<span class="spinner"></span>
<span class="btn-label">Crear Cuenta</span>
</button>
</form>
<p class="toggle-link">
¿Ya tienes cuenta? <a onclick="showPanel('login')">Inicia Sesion</a>
</p>
</div>
</div>
<script src="/login.js"></script>
</body>
</html>

227
dashboard/login.js Normal file
View File

@@ -0,0 +1,227 @@
/* ============================================================
login.js -- Login / Register logic for Nexus Autoparts
============================================================ */
(function () {
'use strict';
// ---- DOM refs ----
const loginPanel = document.getElementById('loginPanel');
const registerPanel = document.getElementById('registerPanel');
const loginForm = document.getElementById('loginForm');
const registerForm = document.getElementById('registerForm');
const alertBox = document.getElementById('alert');
// ---- Role-based redirect map ----
const ROLE_REDIRECTS = {
ADMIN: '/demo',
OWNER: '/demo',
BODEGA: '/bodega',
TALLER: '/demo',
};
// ---- Check existing session on load ----
(function checkSession() {
const token = localStorage.getItem('access_token');
const role = localStorage.getItem('user_role');
if (token && role) {
const dest = ROLE_REDIRECTS[role] || '/index.html';
window.location.replace(dest);
}
})();
// ---- Panel toggling ----
window.showPanel = function (panel) {
hideAlert();
if (panel === 'register') {
loginPanel.classList.remove('active');
registerPanel.classList.add('active');
} else {
registerPanel.classList.remove('active');
loginPanel.classList.add('active');
}
};
// ---- Alert helpers ----
function showAlert(msg, type) {
alertBox.textContent = msg;
alertBox.className = 'login-alert show ' + type;
}
function hideAlert() {
alertBox.className = 'login-alert';
alertBox.textContent = '';
}
function setLoading(btn, loading) {
btn.classList.toggle('loading', loading);
}
// ---- Login ----
loginForm.addEventListener('submit', async function (e) {
e.preventDefault();
hideAlert();
const email = document.getElementById('loginEmail').value.trim();
const password = document.getElementById('loginPassword').value;
const btn = loginForm.querySelector('.btn-submit');
if (!email || !password) {
showAlert('Completa todos los campos.', 'error');
return;
}
setLoading(btn, true);
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) {
showAlert(data.error || data.message || 'Credenciales incorrectas.', 'error');
return;
}
// Persist tokens & user info
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token || '');
localStorage.setItem('user_role', data.role || data.user?.role || '');
localStorage.setItem('user_name', data.name || data.user?.name || '');
const role = (data.role || data.user?.role || '').toUpperCase();
const dest = ROLE_REDIRECTS[role] || '/index.html';
window.location.replace(dest);
} catch (err) {
showAlert('Error de conexion. Intenta de nuevo.', 'error');
} finally {
setLoading(btn, false);
}
});
// ---- Register ----
registerForm.addEventListener('submit', async function (e) {
e.preventDefault();
hideAlert();
const name = document.getElementById('regName').value.trim();
const email = document.getElementById('regEmail').value.trim();
const password = document.getElementById('regPassword').value;
const confirm = document.getElementById('regConfirm').value;
const business_name = document.getElementById('regBusiness').value.trim();
const phone = document.getElementById('regPhone').value.trim();
const role = document.getElementById('regRole').value;
const btn = registerForm.querySelector('.btn-submit');
// Validations
if (!name || !email || !password || !confirm || !business_name || !phone) {
showAlert('Completa todos los campos.', 'error');
return;
}
if (password.length < 8) {
showAlert('La contrasena debe tener al menos 8 caracteres.', 'error');
return;
}
if (password !== confirm) {
showAlert('Las contrasenas no coinciden.', 'error');
return;
}
setLoading(btn, true);
try {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, password, role, business_name, phone }),
});
const data = await res.json();
if (!res.ok) {
showAlert(data.error || data.message || 'Error al crear la cuenta.', 'error');
return;
}
showAlert('Cuenta creada. Pendiente de aprobacion por administrador.', 'success');
registerForm.reset();
} catch (err) {
showAlert('Error de conexion. Intenta de nuevo.', 'error');
} finally {
setLoading(btn, false);
}
});
// ================================================================
// authFetch -- Authenticated fetch wrapper (exported globally)
// ================================================================
window.authFetch = async function authFetch(url, options = {}) {
const token = localStorage.getItem('access_token');
if (!token) {
window.location.replace('/login.html');
return;
}
const headers = Object.assign({}, options.headers || {}, {
'Authorization': 'Bearer ' + token,
});
let res = await fetch(url, Object.assign({}, options, { headers }));
// If 401, try refreshing the token once
if (res.status === 401) {
const refreshed = await tryRefreshToken();
if (refreshed) {
headers['Authorization'] = 'Bearer ' + localStorage.getItem('access_token');
res = await fetch(url, Object.assign({}, options, { headers }));
} else {
// Refresh failed — log out
logout();
return;
}
}
return res;
};
async function tryRefreshToken() {
const refreshToken = localStorage.getItem('refresh_token');
if (!refreshToken) return false;
try {
const res = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!res.ok) return false;
const data = await res.json();
localStorage.setItem('access_token', data.access_token);
if (data.refresh_token) {
localStorage.setItem('refresh_token', data.refresh_token);
}
return true;
} catch (e) {
return false;
}
}
window.logout = function logout() {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user_role');
localStorage.removeItem('user_name');
window.location.replace('/login.html');
};
})();

View File

@@ -21,12 +21,23 @@
if ((h === '/admin.html' || h === '/admin') && (p === '/admin.html' || p === '/admin')) return true; if ((h === '/admin.html' || h === '/admin') && (p === '/admin.html' || p === '/admin')) return true;
if ((h === '/diagramas' || h === '/diagrams.html') && (p === '/diagramas' || p === '/diagrams.html')) return true; if ((h === '/diagramas' || h === '/diagrams.html') && (p === '/diagramas' || p === '/diagrams.html')) return true;
if ((h === '/customer-landing.html') && (p === '/customer-landing.html')) return true; if ((h === '/customer-landing.html') && (p === '/customer-landing.html')) return true;
if ((h === '/captura') && (p === '/captura')) return true;
if ((h === '/pos') && (p === '/pos')) return true;
if ((h === '/cuentas') && (p === '/cuentas')) return true;
if ((h === '/tienda') && (p === '/tienda')) return true;
if ((h === '/bodega') && (p === '/bodega')) return true;
if ((h === '/demo' || h === '/demo.html') && (p === '/demo' || p === '/demo.html')) return true;
return false; return false;
} }
var navLinks = [ var navLinks = [
{ label: 'Cat\u00e1logo', href: '/' }, { label: 'Demo', href: '/demo' },
{ label: 'Diagramas', href: '/diagramas' }, { label: 'Tienda', href: '/tienda' },
{ label: 'Cat\u00e1logo', href: '/index.html' },
{ label: 'Captura', href: '/captura' },
{ label: 'POS', href: '/pos' },
{ label: 'Cuentas', href: '/cuentas' },
{ label: 'Bodega', href: '/bodega' },
{ label: 'Admin', href: '/admin' } { label: 'Admin', href: '/admin' }
]; ];
@@ -99,6 +110,16 @@
+ '">' + '">'
+ linksHTML + linksHTML
+ '</nav>' + '</nav>'
// Auth section
+ '<div id="nav-auth" style="display:flex;align-items:center;gap:0.75rem;flex-shrink:0;">'
+ '<span id="nav-user-name" style="color:var(--text-secondary);font-size:0.85rem;"></span>'
+ '<a id="nav-auth-btn" href="/login.html" style="'
+ 'text-decoration:none;font-size:0.85rem;font-weight:500;'
+ 'color:var(--bg);background:var(--accent);'
+ 'padding:0.4rem 1rem;border-radius:6px;'
+ 'transition:opacity 0.2s;'
+ '">Iniciar Sesi\u00f3n</a>'
+ '</div>'
+ '</div>' + '</div>'
+ '</header>'; + '</header>';
@@ -106,4 +127,29 @@
if (target) { if (target) {
target.innerHTML = html; target.innerHTML = html;
} }
// Auth state
var token = localStorage.getItem('access_token');
if (token) {
try {
var payload = JSON.parse(atob(token.split('.')[1]));
var nameEl = document.getElementById('nav-user-name');
var btnEl = document.getElementById('nav-auth-btn');
if (nameEl && payload.business_name) {
nameEl.textContent = payload.business_name;
} else if (nameEl) {
nameEl.textContent = payload.role || '';
}
if (btnEl) {
btnEl.textContent = 'Salir';
btnEl.href = '#';
btnEl.onclick = function(e) {
e.preventDefault();
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
window.location.href = '/login.html';
};
}
} catch(e) {}
}
})(); })();

418
dashboard/pos.css Normal file
View File

@@ -0,0 +1,418 @@
/* ============================================================
pos.css -- Point of Sale styles
============================================================ */
.pos-container {
max-width: 1400px;
margin: 0 auto;
padding: 5rem 2rem 2rem;
}
/* --- Layout: 2 columns --- */
.pos-layout {
display: grid;
grid-template-columns: 1fr 360px;
gap: 1.5rem;
align-items: start;
}
/* --- Left: Search + Cart --- */
.pos-main {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* --- Customer bar --- */
.customer-bar {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1rem;
display: flex;
gap: 1rem;
align-items: center;
}
.customer-bar .cb-search {
flex: 1;
padding: 0.5rem 0.8rem;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-primary);
font-size: 0.9rem;
}
.customer-bar .cb-search:focus {
outline: none;
border-color: var(--accent);
}
.customer-bar .cb-selected {
display: flex;
align-items: center;
gap: 0.8rem;
flex: 1;
}
.customer-bar .cb-name {
font-weight: 700;
font-size: 1rem;
}
.customer-bar .cb-rfc {
font-size: 0.8rem;
color: var(--text-secondary);
font-family: monospace;
}
.customer-bar .cb-balance {
font-size: 0.85rem;
padding: 0.2rem 0.6rem;
border-radius: 6px;
}
.cb-balance.positive { background: rgba(255, 68, 68, 0.15); color: var(--danger); }
.cb-balance.zero { background: rgba(34, 197, 94, 0.15); color: var(--success); }
/* --- Customer dropdown --- */
.customer-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 0 0 8px 8px;
max-height: 250px;
overflow-y: auto;
z-index: 100;
box-shadow: 0 8px 30px rgba(0,0,0,0.4);
}
.customer-dropdown-item {
padding: 0.6rem 1rem;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border);
}
.customer-dropdown-item:hover {
background: var(--bg-hover);
}
.customer-dropdown-item .cdi-name { font-weight: 600; }
.customer-dropdown-item .cdi-rfc { font-size: 0.8rem; color: var(--text-secondary); }
/* --- Part search --- */
.part-search-wrap {
position: relative;
}
.part-search {
width: 100%;
padding: 0.7rem 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
color: var(--text-primary);
font-size: 1rem;
}
.part-search:focus {
outline: none;
border-color: var(--accent);
}
.part-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 0 0 10px 10px;
max-height: 300px;
overflow-y: auto;
z-index: 100;
box-shadow: 0 8px 30px rgba(0,0,0,0.4);
display: none;
}
.part-result-item {
padding: 0.6rem 1rem;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border);
}
.part-result-item:hover,
.part-result-item.part-result-active {
background: var(--bg-hover);
border-left: 3px solid var(--accent);
}
.part-result-item .pri-number {
font-family: monospace;
font-weight: 600;
color: var(--accent);
}
.part-result-item .pri-name {
font-size: 0.85rem;
color: var(--text-secondary);
margin-left: 0.5rem;
}
.part-result-item .pri-type {
font-size: 0.7rem;
padding: 0.15rem 0.4rem;
border-radius: 4px;
text-transform: uppercase;
}
.pri-type.oem { background: rgba(59, 130, 246, 0.15); color: var(--info); }
.pri-type.aftermarket { background: rgba(245, 158, 11, 0.15); color: var(--warning); }
/* --- Cart table --- */
.cart-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
}
.cart-card h3 {
padding: 0.8rem 1rem;
border-bottom: 1px solid var(--border);
font-size: 0.9rem;
color: var(--text-secondary);
}
.cart-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.cart-table th {
text-align: left;
padding: 0.5rem 0.6rem;
border-bottom: 1px solid var(--border);
color: var(--text-secondary);
font-size: 0.75rem;
text-transform: uppercase;
}
.cart-table td {
padding: 0.5rem 0.6rem;
border-bottom: 1px solid rgba(42, 42, 58, 0.5);
vertical-align: middle;
}
.cart-table input {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-primary);
padding: 0.3rem 0.4rem;
font-size: 0.85rem;
width: 70px;
text-align: right;
}
.cart-table input:focus {
outline: none;
border-color: var(--accent);
}
.cart-table .cart-desc { max-width: 250px; }
.cart-table .cart-qty { width: 45px; text-align: center; }
.cart-table .cart-cost { width: 80px; }
.cart-table .cart-margin { width: 55px; }
.cart-table .cart-price { width: 80px; }
.cart-table .cart-remove {
background: none;
border: none;
color: var(--danger);
cursor: pointer;
font-size: 1rem;
padding: 0.2rem;
}
.cart-empty {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
font-size: 0.9rem;
}
/* --- Right sidebar: Invoice summary --- */
.pos-sidebar {
position: sticky;
top: 5rem;
}
.invoice-summary {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.2rem;
}
.invoice-summary h3 {
font-size: 0.9rem;
color: var(--text-secondary);
margin-bottom: 1rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.summary-row {
display: flex;
justify-content: space-between;
padding: 0.4rem 0;
font-size: 0.9rem;
}
.summary-row.total {
border-top: 2px solid var(--accent);
margin-top: 0.5rem;
padding-top: 0.8rem;
font-size: 1.2rem;
font-weight: 700;
}
.summary-row .sr-label { color: var(--text-secondary); }
.summary-row .sr-value { font-weight: 600; }
.summary-row.total .sr-value { color: var(--accent); }
.btn-facturar {
width: 100%;
margin-top: 1.2rem;
padding: 0.9rem;
font-size: 1rem;
background: linear-gradient(135deg, var(--accent), #ff4500);
border: none;
border-radius: 10px;
color: #fff;
font-weight: 700;
cursor: pointer;
transition: all 0.3s;
}
.btn-facturar:hover {
transform: translateY(-2px);
box-shadow: 0 6px 25px var(--accent-glow);
}
.btn-facturar:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.invoice-notes {
width: 100%;
margin-top: 0.8rem;
padding: 0.5rem;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.85rem;
resize: vertical;
min-height: 60px;
}
.invoice-notes:focus {
outline: none;
border-color: var(--accent);
}
/* --- New customer modal --- */
.modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.6);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
}
.modal-content {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
width: 450px;
max-width: 95vw;
}
.modal-content h3 {
margin-bottom: 1rem;
font-size: 1.1rem;
}
.modal-field {
margin-bottom: 0.8rem;
}
.modal-field label {
display: block;
font-size: 0.75rem;
color: var(--text-secondary);
text-transform: uppercase;
margin-bottom: 0.2rem;
}
.modal-field input {
width: 100%;
padding: 0.5rem;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.9rem;
}
.modal-field input:focus {
outline: none;
border-color: var(--accent);
}
.modal-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1rem;
}
/* --- Toast (reuse from captura) --- */
.toast {
position: fixed;
bottom: 2rem;
right: 2rem;
padding: 0.8rem 1.5rem;
border-radius: 10px;
color: #fff;
font-weight: 600;
font-size: 0.9rem;
z-index: 9999;
animation: toastIn 0.3s ease;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
}
.toast.success { background: var(--success); }
.toast.error { background: var(--danger); }
@keyframes toastIn {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}

113
dashboard/pos.html Normal file
View File

@@ -0,0 +1,113 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Punto de Venta — NEXUS AUTOPARTS</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Orbitron:wght@700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/shared.css">
<link rel="stylesheet" href="/pos.css">
</head>
<body>
<div id="shared-nav"></div>
<div class="pos-container">
<div class="pos-layout">
<!-- LEFT: Main area -->
<div class="pos-main">
<!-- Customer selection -->
<div class="customer-bar" style="position:relative">
<div id="customer-select" style="flex:1;position:relative">
<input class="cb-search" id="customer-search" type="text" placeholder="Buscar cliente por nombre o RFC...">
<div id="customer-dropdown" class="customer-dropdown" style="display:none"></div>
</div>
<div id="customer-info" class="cb-selected" style="display:none">
<span class="cb-name" id="sel-customer-name"></span>
<span class="cb-rfc" id="sel-customer-rfc"></span>
<span class="cb-balance" id="sel-customer-balance"></span>
<button class="btn btn-secondary" style="padding:0.3rem 0.6rem;font-size:0.8rem" id="btn-change-customer">Cambiar</button>
</div>
<button class="btn btn-secondary" id="btn-new-customer" style="padding:0.5rem 0.8rem;font-size:0.85rem">+ Nuevo</button>
</div>
<!-- Part search -->
<div class="part-search-wrap">
<input class="part-search" id="part-search" type="text" placeholder="Buscar parte por # OEM, # aftermarket o nombre...">
<div id="part-results" class="part-results"></div>
</div>
<!-- Cart -->
<div class="cart-card">
<h3>Carrito</h3>
<table class="cart-table">
<thead>
<tr>
<th>Descripcion</th>
<th>Tipo</th>
<th>Cant</th>
<th>Costo</th>
<th>Margen%</th>
<th>Precio</th>
<th>Total</th>
<th></th>
</tr>
</thead>
<tbody id="cart-body">
<tr><td colspan="8" class="cart-empty">Busca y agrega partes al carrito</td></tr>
</tbody>
</table>
</div>
</div>
<!-- RIGHT: Summary -->
<div class="pos-sidebar">
<div class="invoice-summary">
<h3>Resumen de Factura</h3>
<div class="summary-row">
<span class="sr-label">Articulos</span>
<span class="sr-value" id="sum-items">0</span>
</div>
<div class="summary-row">
<span class="sr-label">Subtotal</span>
<span class="sr-value" id="sum-subtotal">$0.00</span>
</div>
<div class="summary-row">
<span class="sr-label">IVA (16%)</span>
<span class="sr-value" id="sum-tax">$0.00</span>
</div>
<div class="summary-row total">
<span class="sr-label">Total</span>
<span class="sr-value" id="sum-total">$0.00</span>
</div>
<textarea class="invoice-notes" id="invoice-notes" placeholder="Notas de la factura (opcional)"></textarea>
<button class="btn-facturar" id="btn-facturar" disabled>Facturar</button>
</div>
</div>
</div>
</div>
<!-- New Customer Modal -->
<div id="modal-new-customer" class="modal-overlay" style="display:none">
<div class="modal-content">
<h3>Nuevo Cliente</h3>
<div class="modal-field"><label>Nombre *</label><input id="nc-name" required></div>
<div class="modal-field"><label>RFC</label><input id="nc-rfc" maxlength="13" placeholder="XAXX010101000"></div>
<div class="modal-field"><label>Razon Social</label><input id="nc-business"></div>
<div class="modal-field"><label>Telefono</label><input id="nc-phone"></div>
<div class="modal-field"><label>Email</label><input id="nc-email" type="email"></div>
<div class="modal-field"><label>Direccion</label><input id="nc-address"></div>
<div style="display:flex;gap:1rem">
<div class="modal-field" style="flex:1"><label>Limite de Credito</label><input id="nc-credit" type="number" value="0"></div>
<div class="modal-field" style="flex:1"><label>Dias de Credito</label><input id="nc-terms" type="number" value="30"></div>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" id="nc-cancel">Cancelar</button>
<button class="btn btn-primary" id="nc-save">Guardar</button>
</div>
</div>
</div>
<script src="/nav.js"></script>
<script src="/pos.js"></script>
</body>
</html>

413
dashboard/pos.js Normal file
View File

@@ -0,0 +1,413 @@
/**
* pos.js — Point of Sale logic for Nexus Autoparts
*/
(function () {
'use strict';
var API = '';
var selectedCustomer = null;
var cart = [];
var defaultMargin = 30;
// ================================================================
// Utility
// ================================================================
function toast(msg, type) {
var el = document.createElement('div');
el.className = 'toast ' + (type || 'success');
el.textContent = msg;
document.body.appendChild(el);
setTimeout(function () { el.remove(); }, 3000);
}
function api(path, opts) {
opts = opts || {};
return fetch(API + path, opts).then(function (r) {
if (!r.ok) return r.json().then(function (d) { throw new Error(d.error || 'Error'); });
return r.json();
});
}
function esc(s) {
if (!s) return '';
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function fmt(n) {
return '$' + (parseFloat(n) || 0).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
// ================================================================
// Customer Selection
// ================================================================
var customerSearchTimer = null;
var customerSearchEl = document.getElementById('customer-search');
var customerDropdown = document.getElementById('customer-dropdown');
customerSearchEl.addEventListener('input', function () {
clearTimeout(customerSearchTimer);
var q = this.value.trim();
if (q.length < 2) { customerDropdown.style.display = 'none'; return; }
customerSearchTimer = setTimeout(function () {
api('/api/pos/customers?search=' + encodeURIComponent(q) + '&per_page=10')
.then(function (res) {
var data = res.data || [];
if (data.length === 0) {
customerDropdown.innerHTML = '<div style="padding:0.8rem;color:var(--text-secondary)">No se encontraron clientes</div>';
} else {
customerDropdown.innerHTML = data.map(function (c) {
return '<div class="customer-dropdown-item" data-id="' + c.id_customer + '">' +
'<div><span class="cdi-name">' + esc(c.name) + '</span>' +
(c.rfc ? ' <span class="cdi-rfc">' + esc(c.rfc) + '</span>' : '') + '</div>' +
'<span style="font-size:0.8rem;color:' + (c.balance > 0 ? 'var(--danger)' : 'var(--success)') + '">' +
fmt(c.balance) + '</span></div>';
}).join('');
customerDropdown.querySelectorAll('.customer-dropdown-item').forEach(function (item) {
item.addEventListener('click', function () {
selectCustomer(parseInt(item.getAttribute('data-id')));
});
});
}
customerDropdown.style.display = 'block';
});
}, 300);
});
customerSearchEl.addEventListener('blur', function () {
setTimeout(function () { customerDropdown.style.display = 'none'; }, 200);
});
function selectCustomer(id) {
api('/api/pos/customers/' + id).then(function (c) {
selectedCustomer = c;
document.getElementById('customer-select').style.display = 'none';
var info = document.getElementById('customer-info');
info.style.display = 'flex';
document.getElementById('sel-customer-name').textContent = c.name;
document.getElementById('sel-customer-rfc').textContent = c.rfc || 'Sin RFC';
var balEl = document.getElementById('sel-customer-balance');
balEl.textContent = 'Saldo: ' + fmt(c.balance);
balEl.className = 'cb-balance ' + (c.balance > 0 ? 'positive' : 'zero');
customerDropdown.style.display = 'none';
updateFacturarBtn();
});
}
document.getElementById('btn-change-customer').addEventListener('click', function () {
selectedCustomer = null;
document.getElementById('customer-info').style.display = 'none';
document.getElementById('customer-select').style.display = 'block';
customerSearchEl.value = '';
customerSearchEl.focus();
updateFacturarBtn();
});
// ================================================================
// New Customer Modal
// ================================================================
document.getElementById('btn-new-customer').addEventListener('click', function () {
document.getElementById('modal-new-customer').style.display = 'flex';
document.getElementById('nc-name').focus();
});
document.getElementById('nc-cancel').addEventListener('click', function () {
document.getElementById('modal-new-customer').style.display = 'none';
});
document.getElementById('nc-save').addEventListener('click', function () {
var name = document.getElementById('nc-name').value.trim();
if (!name) { toast('Ingresa el nombre del cliente', 'error'); return; }
api('/api/pos/customers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name,
rfc: document.getElementById('nc-rfc').value.trim() || null,
business_name: document.getElementById('nc-business').value.trim() || null,
phone: document.getElementById('nc-phone').value.trim() || null,
email: document.getElementById('nc-email').value.trim() || null,
address: document.getElementById('nc-address').value.trim() || null,
credit_limit: parseFloat(document.getElementById('nc-credit').value) || 0,
payment_terms: parseInt(document.getElementById('nc-terms').value) || 30
})
}).then(function (res) {
toast('Cliente creado: ' + name);
document.getElementById('modal-new-customer').style.display = 'none';
selectCustomer(res.id);
// Clear form
['nc-name','nc-rfc','nc-business','nc-phone','nc-email','nc-address'].forEach(function(id) {
document.getElementById(id).value = '';
});
document.getElementById('nc-credit').value = '0';
document.getElementById('nc-terms').value = '30';
}).catch(function (err) {
toast(err.message, 'error');
});
});
// ================================================================
// Part Search — Autocomplete
// ================================================================
var partSearchTimer = null;
var partSearchEl = document.getElementById('part-search');
var partResults = document.getElementById('part-results');
var searchResults = [];
var highlightIdx = -1;
function doPartSearch() {
var q = partSearchEl.value.trim();
if (q.length < 1) { partResults.style.display = 'none'; searchResults = []; return; }
clearTimeout(partSearchTimer);
partSearchTimer = setTimeout(function () {
api('/api/pos/search-parts?q=' + encodeURIComponent(q)).then(function (results) {
searchResults = results;
highlightIdx = -1;
renderSearchResults();
});
}, 150);
}
function renderSearchResults() {
if (searchResults.length === 0 && partSearchEl.value.trim().length > 0) {
partResults.innerHTML = '<div style="padding:0.8rem;color:var(--text-secondary)">No se encontraron partes para "' + esc(partSearchEl.value) + '"</div>';
partResults.style.display = 'block';
return;
}
if (searchResults.length === 0) { partResults.style.display = 'none'; return; }
partResults.innerHTML = searchResults.map(function (p, i) {
var active = i === highlightIdx ? ' part-result-active' : '';
return '<div class="part-result-item' + active + '" data-idx="' + i + '">' +
'<div><span class="pri-number">' + esc(p.oem_part_number) + '</span>' +
'<span class="pri-name">' + esc(p.name_part) + '</span></div>' +
'<div style="display:flex;align-items:center;gap:0.4rem">' +
'<span class="pri-type ' + p.part_type + '">' + p.part_type + '</span>' +
(p.cost_usd ? '<span style="font-size:0.8rem;color:var(--text-secondary)">' + fmt(p.cost_usd) + '</span>' : '') +
'<span style="font-size:0.75rem;color:var(--text-secondary)">' + esc(p.group_name || '') + '</span>' +
'</div></div>';
}).join('');
partResults.querySelectorAll('.part-result-item').forEach(function (item) {
item.addEventListener('mousedown', function (e) {
e.preventDefault();
selectSearchResult(parseInt(item.getAttribute('data-idx')));
});
item.addEventListener('mouseenter', function () {
highlightIdx = parseInt(item.getAttribute('data-idx'));
updateHighlight();
});
});
partResults.style.display = 'block';
}
function updateHighlight() {
partResults.querySelectorAll('.part-result-item').forEach(function (el, i) {
if (i === highlightIdx) {
el.classList.add('part-result-active');
el.scrollIntoView({ block: 'nearest' });
} else {
el.classList.remove('part-result-active');
}
});
}
function selectSearchResult(idx) {
if (idx >= 0 && idx < searchResults.length) {
addToCart(searchResults[idx]);
partSearchEl.value = '';
partResults.style.display = 'none';
searchResults = [];
highlightIdx = -1;
partSearchEl.focus();
}
}
partSearchEl.addEventListener('input', doPartSearch);
partSearchEl.addEventListener('keydown', function (e) {
if (partResults.style.display === 'none' || searchResults.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
highlightIdx = Math.min(highlightIdx + 1, searchResults.length - 1);
updateHighlight();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
highlightIdx = Math.max(highlightIdx - 1, 0);
updateHighlight();
} else if (e.key === 'Enter') {
e.preventDefault();
if (highlightIdx >= 0) {
selectSearchResult(highlightIdx);
} else if (searchResults.length === 1) {
selectSearchResult(0);
}
} else if (e.key === 'Escape') {
partResults.style.display = 'none';
highlightIdx = -1;
}
});
partSearchEl.addEventListener('focus', function () {
if (searchResults.length > 0) {
partResults.style.display = 'block';
}
});
partSearchEl.addEventListener('blur', function () {
setTimeout(function () { partResults.style.display = 'none'; }, 200);
});
// ================================================================
// Cart
// ================================================================
function addToCart(part) {
cart.push({
part_id: part.part_type === 'oem' ? part.id_part : null,
aftermarket_id: part.part_type === 'aftermarket' ? part.id_part : null,
description: (part.oem_part_number || '') + ' - ' + (part.name_part || ''),
part_type: part.part_type,
quantity: 1,
unit_cost: part.cost_usd || 0,
margin_pct: defaultMargin,
unit_price: (part.cost_usd || 0) * (1 + defaultMargin / 100)
});
renderCart();
partSearchEl.focus();
}
function renderCart() {
var tbody = document.getElementById('cart-body');
if (cart.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="cart-empty">Busca y agrega partes al carrito</td></tr>';
updateTotals();
return;
}
tbody.innerHTML = cart.map(function (item, i) {
var lineTotal = item.quantity * item.unit_price;
return '<tr>' +
'<td class="cart-desc">' + esc(item.description) + '</td>' +
'<td><span class="pri-type ' + item.part_type + '">' + item.part_type + '</span></td>' +
'<td><input class="cart-qty" type="number" min="1" value="' + item.quantity + '" data-idx="' + i + '" data-field="quantity"></td>' +
'<td><input class="cart-cost" type="number" step="0.01" value="' + item.unit_cost.toFixed(2) + '" data-idx="' + i + '" data-field="unit_cost"></td>' +
'<td><input class="cart-margin" type="number" step="1" value="' + item.margin_pct.toFixed(0) + '" data-idx="' + i + '" data-field="margin_pct">%</td>' +
'<td>' + fmt(item.unit_price) + '</td>' +
'<td>' + fmt(lineTotal) + '</td>' +
'<td><button class="cart-remove" data-idx="' + i + '">&times;</button></td>' +
'</tr>';
}).join('');
// Input change handlers
tbody.querySelectorAll('input').forEach(function (input) {
input.addEventListener('change', function () {
var idx = parseInt(input.getAttribute('data-idx'));
var field = input.getAttribute('data-field');
var val = parseFloat(input.value) || 0;
cart[idx][field] = val;
// Recalculate price from cost + margin
if (field === 'unit_cost' || field === 'margin_pct') {
cart[idx].unit_price = cart[idx].unit_cost * (1 + cart[idx].margin_pct / 100);
}
renderCart();
});
});
// Remove handlers
tbody.querySelectorAll('.cart-remove').forEach(function (btn) {
btn.addEventListener('click', function () {
cart.splice(parseInt(btn.getAttribute('data-idx')), 1);
renderCart();
});
});
updateTotals();
}
function updateTotals() {
var itemCount = cart.reduce(function (sum, it) { return sum + it.quantity; }, 0);
var subtotal = cart.reduce(function (sum, it) { return sum + (it.quantity * it.unit_price); }, 0);
var tax = subtotal * 0.16;
var total = subtotal + tax;
document.getElementById('sum-items').textContent = itemCount;
document.getElementById('sum-subtotal').textContent = fmt(subtotal);
document.getElementById('sum-tax').textContent = fmt(tax);
document.getElementById('sum-total').textContent = fmt(total);
updateFacturarBtn();
}
function updateFacturarBtn() {
document.getElementById('btn-facturar').disabled = !(selectedCustomer && cart.length > 0);
}
// ================================================================
// Facturar
// ================================================================
document.getElementById('btn-facturar').addEventListener('click', function () {
if (!selectedCustomer || cart.length === 0) return;
var btn = this;
btn.disabled = true;
btn.textContent = 'Generando...';
var items = cart.map(function (it) {
return {
part_id: it.part_id,
aftermarket_id: it.aftermarket_id,
description: it.description,
quantity: it.quantity,
unit_cost: it.unit_cost,
margin_pct: it.margin_pct,
unit_price: Math.round(it.unit_price * 100) / 100
};
});
api('/api/pos/invoices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
customer_id: selectedCustomer.id_customer,
items: items,
notes: document.getElementById('invoice-notes').value.trim()
})
}).then(function (res) {
toast('Factura ' + res.folio + ' creada por ' + fmt(res.total));
// Reset cart
cart = [];
renderCart();
document.getElementById('invoice-notes').value = '';
// Refresh customer balance
selectCustomer(selectedCustomer.id_customer);
btn.textContent = 'Facturar';
}).catch(function (err) {
toast(err.message, 'error');
btn.disabled = false;
btn.textContent = 'Facturar';
});
});
// ================================================================
// Init
// ================================================================
renderCart();
})();

File diff suppressed because it is too large Load Diff

678
dashboard/tienda.css Normal file
View File

@@ -0,0 +1,678 @@
/* ============================================================
tienda.css -- Store / Tablet dashboard styles
Nexus Autoparts — tablet-first, touch-friendly
============================================================ */
/* --- Base overrides for tienda page --- */
body {
margin: 0;
padding: 0;
font-family: 'DM Sans', sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
overscroll-behavior: none;
}
/* --- Header --- */
.t-header {
position: fixed;
top: 0; left: 0; right: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 1.2rem;
background: rgba(18, 18, 26, 0.92);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border-bottom: 1px solid var(--border);
}
.t-header-left {
display: flex;
align-items: center;
gap: 0.6rem;
flex-shrink: 0;
}
.t-logo-mark {
width: 36px;
height: 36px;
background: linear-gradient(135deg, var(--accent) 0%, #ff4500 100%);
border-radius: 9px;
box-shadow: 0 3px 14px var(--accent-glow);
display: flex;
align-items: center;
justify-content: center;
}
.t-logo-mark::after {
content: '\2699\FE0F';
font-size: 1.2rem;
}
.t-brand {
display: flex;
flex-direction: column;
line-height: 1.1;
}
.t-brand-name {
font-family: 'Outfit', sans-serif;
font-weight: 800;
font-size: 1.1rem;
background: linear-gradient(135deg, #fff 0%, var(--accent) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.t-brand-sub {
font-size: 0.55rem;
font-weight: 600;
color: var(--text-secondary);
letter-spacing: 0.15em;
text-transform: uppercase;
}
/* --- Header center: search --- */
.t-header-center {
flex: 1;
max-width: 420px;
margin: 0 1rem;
}
.t-search-box {
position: relative;
display: flex;
align-items: center;
}
.t-search-icon {
position: absolute;
left: 0.7rem;
width: 18px;
height: 18px;
color: var(--text-secondary);
pointer-events: none;
}
.t-search-box input {
width: 100%;
padding: 0.55rem 0.8rem 0.55rem 2.2rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
color: var(--text-primary);
font-family: 'DM Sans', sans-serif;
font-size: 0.85rem;
outline: none;
transition: border-color 0.2s;
}
.t-search-box input:focus {
border-color: var(--accent);
}
.t-search-box input::placeholder {
color: var(--text-secondary);
}
.t-search-results {
position: absolute;
top: calc(100% + 4px);
left: 0; right: 0;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
max-height: 300px;
overflow-y: auto;
box-shadow: 0 12px 40px rgba(0,0,0,0.5);
display: none;
z-index: 200;
}
.t-search-results.active {
display: block;
}
.t-search-result-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.6rem 0.8rem;
border-bottom: 1px solid var(--border);
cursor: pointer;
transition: background 0.15s;
}
.t-search-result-item:last-child {
border-bottom: none;
}
.t-search-result-item:hover,
.t-search-result-item:active {
background: var(--bg-hover);
}
.t-search-result-item .sri-number {
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
font-size: 0.85rem;
color: var(--accent);
}
.t-search-result-item .sri-name {
font-size: 0.8rem;
color: var(--text-secondary);
margin-left: 0.4rem;
}
/* --- Header right: clock --- */
.t-header-right {
flex-shrink: 0;
}
.t-clock {
font-family: 'JetBrains Mono', monospace;
font-size: 0.85rem;
font-weight: 500;
color: var(--text-secondary);
letter-spacing: 0.03em;
}
/* --- Main --- */
.t-main {
padding: 4.2rem 1rem 1.5rem;
max-width: 1200px;
margin: 0 auto;
}
/* --- KPI Row --- */
.t-kpi-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.8rem;
margin-bottom: 1rem;
}
.t-kpi {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 0.9rem 1rem;
display: flex;
align-items: center;
gap: 0.8rem;
position: relative;
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
}
.t-kpi:active {
transform: scale(0.98);
}
/* Colored left accent bar */
.t-kpi::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
border-radius: 3px 0 0 3px;
}
.t-kpi[data-color="accent"]::before { background: var(--accent); }
.t-kpi[data-color="success"]::before { background: var(--success); }
.t-kpi[data-color="info"]::before { background: var(--info); }
.t-kpi[data-color="warning"]::before { background: var(--warning); }
.t-kpi-icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.t-kpi-icon svg {
width: 22px;
height: 22px;
}
.t-kpi[data-color="accent"] .t-kpi-icon { background: rgba(255, 107, 53, 0.12); color: var(--accent); }
.t-kpi[data-color="success"] .t-kpi-icon { background: rgba(34, 197, 94, 0.12); color: var(--success); }
.t-kpi[data-color="info"] .t-kpi-icon { background: rgba(59, 130, 246, 0.12); color: var(--info); }
.t-kpi[data-color="warning"] .t-kpi-icon { background: rgba(245, 158, 11, 0.12); color: var(--warning); }
.t-kpi-data {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.t-kpi-value {
font-family: 'Outfit', sans-serif;
font-weight: 700;
font-size: 1.3rem;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.t-kpi-label {
font-size: 0.72rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 500;
}
.t-kpi-count {
font-size: 0.65rem;
color: var(--text-secondary);
font-family: 'JetBrains Mono', monospace;
white-space: nowrap;
align-self: flex-start;
margin-top: 0.2rem;
}
/* --- Content Grid --- */
.t-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.8rem;
}
/* --- Cards --- */
.t-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1rem;
}
.t-card-full {
min-height: 0;
}
.t-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.8rem;
}
.t-card-title {
font-family: 'DM Sans', sans-serif;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.8rem;
}
.t-card-header .t-card-title {
margin-bottom: 0;
}
.t-see-all {
font-size: 0.75rem;
color: var(--accent);
text-decoration: none;
font-weight: 600;
padding: 0.3rem 0.6rem;
border-radius: 6px;
transition: background 0.2s;
}
.t-see-all:hover,
.t-see-all:active {
background: rgba(255, 107, 53, 0.1);
}
/* --- Quick Actions Grid --- */
.t-actions-card {
padding-bottom: 0.8rem;
}
.t-actions-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.6rem;
}
.t-action {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.8rem;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 10px;
text-decoration: none;
color: var(--text-primary);
font-size: 0.85rem;
font-weight: 600;
transition: transform 0.15s, background 0.2s, border-color 0.2s;
-webkit-tap-highlight-color: transparent;
}
.t-action:active {
transform: scale(0.96);
}
.t-action:hover {
background: var(--bg-hover);
}
.t-action-icon {
width: 36px;
height: 36px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.t-action-icon svg {
width: 20px;
height: 20px;
}
.t-action[data-color="accent"] .t-action-icon { background: rgba(255, 107, 53, 0.12); color: var(--accent); }
.t-action[data-color="accent"]:hover { border-color: var(--accent); }
.t-action[data-color="info"] .t-action-icon { background: rgba(59, 130, 246, 0.12); color: var(--info); }
.t-action[data-color="info"]:hover { border-color: var(--info); }
.t-action[data-color="success"] .t-action-icon { background: rgba(34, 197, 94, 0.12); color: var(--success); }
.t-action[data-color="success"]:hover { border-color: var(--success); }
.t-action[data-color="warning"] .t-action-icon { background: rgba(245, 158, 11, 0.12); color: var(--warning); }
.t-action[data-color="warning"]:hover { border-color: var(--warning); }
/* --- Debtors List --- */
.t-debtors-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
max-height: 280px;
overflow-y: auto;
}
.t-debtor {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.6rem 0.7rem;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.t-debtor:hover,
.t-debtor:active {
background: var(--bg-hover);
border-color: var(--danger);
}
.t-debtor-name {
font-weight: 600;
font-size: 0.85rem;
}
.t-debtor-invoices {
font-size: 0.7rem;
color: var(--text-secondary);
}
.t-debtor-amount {
font-family: 'JetBrains Mono', monospace;
font-weight: 700;
font-size: 0.9rem;
color: var(--danger);
}
/* --- Invoice List --- */
.t-invoice-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
max-height: 320px;
overflow-y: auto;
}
.t-invoice {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.6rem 0.7rem;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 8px;
transition: background 0.15s;
}
.t-invoice:hover,
.t-invoice:active {
background: var(--bg-hover);
}
.t-invoice-left {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.t-invoice-folio {
font-family: 'JetBrains Mono', monospace;
font-weight: 700;
font-size: 0.85rem;
color: var(--accent);
}
.t-invoice-customer {
font-size: 0.75rem;
color: var(--text-secondary);
}
.t-invoice-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.15rem;
}
.t-invoice-total {
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
font-size: 0.85rem;
}
.t-invoice-status {
font-size: 0.65rem;
font-weight: 600;
padding: 0.15rem 0.45rem;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.t-invoice-status.paid {
background: rgba(34, 197, 94, 0.15);
color: var(--success);
}
.t-invoice-status.pending {
background: rgba(245, 158, 11, 0.15);
color: var(--warning);
}
.t-invoice-status.partial {
background: rgba(59, 130, 246, 0.15);
color: var(--info);
}
.t-invoice-status.cancelled {
background: rgba(255, 68, 68, 0.15);
color: var(--danger);
}
/* --- Today's Payments card --- */
.t-today-payments {
text-align: center;
padding: 0.5rem 0;
}
.t-today-amount {
font-family: 'Outfit', sans-serif;
font-weight: 800;
font-size: 2rem;
color: var(--success);
line-height: 1.2;
}
.t-today-count {
font-size: 0.8rem;
color: var(--text-secondary);
margin-top: 0.3rem;
}
/* --- Empty state --- */
.t-empty {
text-align: center;
padding: 1.5rem;
color: var(--text-secondary);
font-size: 0.85rem;
}
/* --- Scrollbar (minimal for touch) --- */
.t-debtors-list::-webkit-scrollbar,
.t-invoice-list::-webkit-scrollbar,
.t-search-results::-webkit-scrollbar {
width: 4px;
}
.t-debtors-list::-webkit-scrollbar-track,
.t-invoice-list::-webkit-scrollbar-track,
.t-search-results::-webkit-scrollbar-track {
background: transparent;
}
.t-debtors-list::-webkit-scrollbar-thumb,
.t-invoice-list::-webkit-scrollbar-thumb,
.t-search-results::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 2px;
}
/* --- Responsive --- */
/* Tablet landscape (default target) */
@media (max-width: 1024px) {
.t-main {
padding: 4rem 0.8rem 1.2rem;
}
.t-kpi-row {
grid-template-columns: repeat(2, 1fr);
}
}
/* Tablet portrait / large phone */
@media (max-width: 768px) {
.t-header-center {
display: none;
}
.t-main {
padding: 3.8rem 0.6rem 1rem;
}
.t-content {
grid-template-columns: 1fr;
}
.t-kpi-row {
grid-template-columns: repeat(2, 1fr);
gap: 0.6rem;
}
.t-kpi {
padding: 0.7rem 0.8rem;
}
.t-kpi-value {
font-size: 1.1rem;
}
.t-kpi-count {
display: none;
}
.t-actions-grid {
grid-template-columns: 1fr 1fr;
}
}
/* Small phone */
@media (max-width: 480px) {
.t-kpi-row {
grid-template-columns: 1fr 1fr;
}
.t-kpi-icon {
width: 32px;
height: 32px;
}
.t-kpi-icon svg {
width: 18px;
height: 18px;
}
.t-kpi-value {
font-size: 1rem;
}
.t-actions-grid {
grid-template-columns: 1fr;
}
}
/* --- Fade-in animation for cards --- */
@keyframes t-fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.t-kpi {
animation: t-fadeIn 0.4s ease both;
}
.t-kpi:nth-child(1) { animation-delay: 0.05s; }
.t-kpi:nth-child(2) { animation-delay: 0.1s; }
.t-kpi:nth-child(3) { animation-delay: 0.15s; }
.t-kpi:nth-child(4) { animation-delay: 0.2s; }
.t-card {
animation: t-fadeIn 0.4s ease both;
animation-delay: 0.25s;
}
.t-content .t-col:nth-child(2) .t-card {
animation-delay: 0.3s;
}
.t-content .t-col:nth-child(2) .t-card:nth-child(2) {
animation-delay: 0.35s;
}

156
dashboard/tienda.html Normal file
View File

@@ -0,0 +1,156 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Tienda — NEXUS AUTOPARTS</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700;800&family=Outfit:wght@700;800;900&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/shared.css">
<link rel="stylesheet" href="/tienda.css">
<link rel="manifest" crossorigin="use-credentials">
<meta name="theme-color" content="#0a0a0f">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
</head>
<body>
<!-- Header -->
<header class="t-header">
<div class="t-header-left">
<div class="t-logo-mark"></div>
<div class="t-brand">
<span class="t-brand-name">NEXUS</span>
<span class="t-brand-sub">AUTOPARTS</span>
</div>
</div>
<div class="t-header-center">
<div class="t-search-box">
<svg class="t-search-icon" 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 id="global-search" type="text" placeholder="Buscar parte, OEM, cliente..." autocomplete="off">
<div id="global-results" class="t-search-results"></div>
</div>
</div>
<div class="t-header-right">
<span class="t-clock" id="clock"></span>
</div>
</header>
<!-- Main Grid -->
<main class="t-main">
<!-- KPI Row -->
<section class="t-kpi-row">
<div class="t-kpi" data-color="accent">
<div class="t-kpi-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6"/></svg>
</div>
<div class="t-kpi-data">
<span class="t-kpi-value" id="kpi-sales-today">$0</span>
<span class="t-kpi-label">Ventas hoy</span>
</div>
<span class="t-kpi-count" id="kpi-sales-count">0 facturas</span>
</div>
<div class="t-kpi" data-color="success">
<div class="t-kpi-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
</div>
<div class="t-kpi-data">
<span class="t-kpi-value" id="kpi-month">$0</span>
<span class="t-kpi-label">Ventas del mes</span>
</div>
<span class="t-kpi-count" id="kpi-month-count">0 facturas</span>
</div>
<div class="t-kpi" data-color="info">
<div class="t-kpi-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>
</div>
<div class="t-kpi-data">
<span class="t-kpi-value" id="kpi-customers">0</span>
<span class="t-kpi-label">Clientes activos</span>
</div>
<span class="t-kpi-count" id="kpi-parts-count">0 partes</span>
</div>
<div class="t-kpi" data-color="warning">
<div class="t-kpi-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="4" width="22" height="16" rx="2"/><path d="M1 10h22"/></svg>
</div>
<div class="t-kpi-data">
<span class="t-kpi-value" id="kpi-pending">$0</span>
<span class="t-kpi-label">Por cobrar</span>
</div>
<span class="t-kpi-count" id="kpi-pending-count">0 facturas</span>
</div>
</section>
<!-- Content Grid: 2 columns -->
<section class="t-content">
<!-- Left column -->
<div class="t-col">
<!-- Quick Actions -->
<div class="t-card t-actions-card">
<h2 class="t-card-title">Acciones</h2>
<div class="t-actions-grid">
<a href="/pos" class="t-action" data-color="accent">
<div class="t-action-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
</div>
<span>Nueva Venta</span>
</a>
<a href="/cuentas" class="t-action" data-color="info">
<div class="t-action-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 00-4-4H5a4 4 0 00-4-4v2"/><circle cx="8.5" cy="7" r="4"/><path d="M20 8v6M23 11h-6"/></svg>
</div>
<span>Cuentas</span>
</a>
<a href="/captura" class="t-action" data-color="success">
<div class="t-action-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</div>
<span>Captura</span>
</a>
<a href="/" class="t-action" data-color="warning">
<div class="t-action-icon">
<svg 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>
</div>
<span>Catalogo</span>
</a>
</div>
</div>
<!-- Top Debtors -->
<div class="t-card">
<h2 class="t-card-title">Cuentas pendientes</h2>
<div id="debtors-list" class="t-debtors-list">
<div class="t-empty">Sin cuentas pendientes</div>
</div>
</div>
</div>
<!-- Right column -->
<div class="t-col">
<!-- Recent Invoices -->
<div class="t-card t-card-full">
<div class="t-card-header">
<h2 class="t-card-title">Ultimas facturas</h2>
<a href="/cuentas" class="t-see-all">Ver todas</a>
</div>
<div id="recent-invoices" class="t-invoice-list">
<div class="t-empty">Sin facturas recientes</div>
</div>
</div>
<!-- Cobros de hoy -->
<div class="t-card">
<div class="t-card-header">
<h2 class="t-card-title">Cobros de hoy</h2>
</div>
<div class="t-today-payments">
<div class="t-today-amount" id="payments-today-amount">$0.00</div>
<div class="t-today-count" id="payments-today-count">0 pagos registrados</div>
</div>
</div>
</div>
</section>
</main>
<script src="/tienda.js"></script>
</body>
</html>

187
dashboard/tienda.js Normal file
View File

@@ -0,0 +1,187 @@
/**
* tienda.js — Store / Tablet dashboard logic for Nexus Autoparts
*/
(function () {
'use strict';
var API = '';
// ================================================================
// Utility
// ================================================================
function fmt(n) {
return '$' + (parseFloat(n) || 0).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
function esc(s) {
if (!s) return '';
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// ================================================================
// Clock
// ================================================================
function updateClock() {
var now = new Date();
var h = now.getHours();
var m = String(now.getMinutes()).padStart(2, '0');
var ampm = h >= 12 ? 'PM' : 'AM';
h = h % 12 || 12;
document.getElementById('clock').textContent = h + ':' + m + ' ' + ampm;
}
updateClock();
setInterval(updateClock, 30000);
// ================================================================
// Load Dashboard Stats
// ================================================================
function loadStats() {
fetch(API + '/api/tienda/stats')
.then(function (r) { return r.json(); })
.then(function (d) {
var st = d.sales_today || {};
var sm = d.sales_month || {};
var pt = d.payments_today || {};
// KPIs
document.getElementById('kpi-sales-today').textContent = fmt(st.total);
document.getElementById('kpi-sales-count').textContent = (st.count || 0) + ' facturas';
document.getElementById('kpi-month').textContent = fmt(sm.total);
document.getElementById('kpi-month-count').textContent = (sm.count || 0) + ' facturas';
document.getElementById('kpi-customers').textContent = d.total_customers || 0;
document.getElementById('kpi-parts-count').textContent = (d.total_parts || 0) + ' partes';
document.getElementById('kpi-pending').textContent = fmt(d.pending_balance || 0);
document.getElementById('kpi-pending-count').textContent = (d.pending_invoices || 0) + ' facturas';
// Today's payments
document.getElementById('payments-today-amount').textContent = fmt(pt.total);
document.getElementById('payments-today-count').textContent = (pt.count || 0) + ' pagos registrados';
// Top debtors
renderDebtors(d.top_debtors || []);
// Recent invoices
renderInvoices(d.recent_invoices || []);
})
.catch(function (err) {
console.error('Error loading stats:', err);
});
}
// ================================================================
// Render Debtors
// ================================================================
function renderDebtors(debtors) {
var el = document.getElementById('debtors-list');
if (debtors.length === 0) {
el.innerHTML = '<div class="t-empty">Sin cuentas pendientes</div>';
return;
}
el.innerHTML = debtors.map(function (d) {
var limitPct = d.credit_limit > 0 ? Math.round(d.balance / d.credit_limit * 100) : 0;
return '<a href="/cuentas" class="t-debtor">'
+ '<div>'
+ '<div class="t-debtor-name">' + esc(d.name) + '</div>'
+ (d.credit_limit > 0 ? '<div class="t-debtor-invoices">' + limitPct + '% de l\u00edmite</div>' : '')
+ '</div>'
+ '<span class="t-debtor-amount">' + fmt(d.balance) + '</span>'
+ '</a>';
}).join('');
}
// ================================================================
// Render Recent Invoices
// ================================================================
function renderInvoices(invoices) {
var el = document.getElementById('recent-invoices');
if (invoices.length === 0) {
el.innerHTML = '<div class="t-empty">Sin facturas recientes</div>';
return;
}
el.innerHTML = invoices.map(function (inv) {
var statusClass = inv.status || 'pending';
var statusLabel = { pending: 'Pendiente', paid: 'Pagada', partial: 'Parcial', cancelled: 'Cancelada' };
return '<div class="t-invoice">'
+ '<div class="t-invoice-left">'
+ '<span class="t-invoice-folio">' + esc(inv.folio) + '</span>'
+ '<span class="t-invoice-customer">' + esc(inv.customer_name) + '</span>'
+ '</div>'
+ '<div class="t-invoice-right">'
+ '<span class="t-invoice-total">' + fmt(inv.total) + '</span>'
+ '<span class="t-invoice-status ' + statusClass + '">' + (statusLabel[statusClass] || statusClass) + '</span>'
+ '</div>'
+ '</div>';
}).join('');
}
// ================================================================
// Global Search
// ================================================================
var searchTimer = null;
var searchInput = document.getElementById('global-search');
var searchResults = document.getElementById('global-results');
if (searchInput) {
searchInput.addEventListener('input', function () {
clearTimeout(searchTimer);
var q = this.value.trim();
if (q.length < 2) {
searchResults.classList.remove('active');
searchResults.innerHTML = '';
return;
}
searchTimer = setTimeout(function () {
fetch(API + '/api/pos/search-parts?q=' + encodeURIComponent(q))
.then(function (r) { return r.json(); })
.then(function (results) {
if (results.length === 0) {
searchResults.innerHTML = '<div style="padding:0.8rem;color:var(--text-secondary);font-size:0.85rem">Sin resultados para "' + esc(q) + '"</div>';
} else {
searchResults.innerHTML = results.slice(0, 8).map(function (p) {
return '<div class="t-search-result-item">'
+ '<div>'
+ '<span class="sri-number">' + esc(p.oem_part_number) + '</span>'
+ '<span class="sri-name">' + esc(p.name_part) + '</span>'
+ '</div>'
+ '</div>';
}).join('');
}
searchResults.classList.add('active');
});
}, 250);
});
searchInput.addEventListener('blur', function () {
setTimeout(function () { searchResults.classList.remove('active'); }, 200);
});
searchInput.addEventListener('focus', function () {
if (searchResults.innerHTML.trim()) {
searchResults.classList.add('active');
}
});
}
// ================================================================
// Init
// ================================================================
loadStats();
// Auto-refresh every 2 minutes
setInterval(loadStats, 120000);
})();

File diff suppressed because it is too large Load Diff

449
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,449 @@
# Arquitectura - Nexus Autoparts
## Vista General del Sistema
```
+-------------------+ +-------------------+ +-------------------+
| | | | | |
| TecDoc (Apify) | | NHTSA VIN API | | CSV/Excel |
| Actor API | | vpic.nhtsa.gov | | (Bodega upload) |
| | | | | |
+--------+----------+ +--------+----------+ +--------+----------+
| | |
| download/import | decode | upload
| | |
v v v
+--------+---------------------------+---------------------------+----------+
| |
| Flask Server (server.py) |
| Puerto 5000 |
| |
| +-------------+ +-------------+ +-------------+ +-----------------+ |
| | Auth Module | | Catalog API | | Inventory | | Admin CRUD | |
| | (auth.py) | | (publico) | | (BODEGA) | | (import/export) | |
| | JWT + bcrypt | | | | CSV mapping | | | |
| +------+------+ +------+------+ +------+------+ +--------+--------+ |
| | | | | |
+---------+----------------+----------------+-------------------+-----------+
| | | |
v v v v
+-------------------------------------------------------------------------+
| |
| PostgreSQL (nexus_autoparts) |
| SQLAlchemy text() raw SQL |
| |
| +----------+ +----------+ +----------+ +----------+ +---------------+ |
| | brands | | models | | years | | engines | | fuel_type | |
| +----+-----+ +----+-----+ +----+-----+ +----+-----+ | drivetrain | |
| | | | | | transmission | |
| +------+------+------------+------------+ +---------------+ |
| | |
| v |
| +-----------------------+ |
| | model_year_engine | (MYE - tabla central de vehiculos) |
| | PK: id_mye | |
| | UNIQUE(model,year, | |
| | engine,trim_level) | |
| +----------+------------+ |
| | |
| | 1:N |
| v |
| +---------------------+ +---------------------+ |
| | vehicle_parts | | vehicle_diagrams | |
| | (12B+ rows) | | | |
| | mye_id + part_id | | mye_id + diagram_id | |
| +----------+----------+ +----------+----------+ |
| | | |
| v v |
| +---------------------+ +---------------------+ |
| | parts (1.4M+ OEM) | | diagrams | |
| | oem_part_number | | image_path | |
| | group_id -> groups | | group_id -> groups | |
| +---+------+----------+ +-----+---------------+ |
| | | | |
| | v v |
| | +--------------------+ +---------------------+ |
| | | part_groups | | diagram_hotspots | |
| | | category_id | | part_id, coords | |
| | +--------+-----------+ +---------------------+ |
| | | |
| | v |
| | +--------------------+ |
| | | part_categories | |
| | | (arbol jerarquico) | |
| | | parent_id -> self | |
| | +--------------------+ |
| | |
| +----------+-----------+ |
| | | |
| v v |
| +---------------------+ +------------------------+ |
| | aftermarket_parts | | part_cross_references | |
| | (300K+) | | (13M+) | |
| | oem_part_id -> parts| | part_id -> parts | |
| | manufacturer_id | | reference_type | |
| +----------+----------+ +------------------------+ |
| | |
| v |
| +---------------------+ |
| | manufacturers | |
| | type, quality_tier | |
| +---------------------+ |
| |
| === SaaS Tables === |
| |
| +----------+ +----------+ +---------------------+ +-------------+ |
| | users | | roles | | warehouse_inventory | | sessions | |
| | id_user | | ADMIN | | user_id + part_id | | refresh | |
| | id_rol | | OWNER | | price, stock | | token | |
| | email | | TALLER | | location | | expires_at | |
| | pass | | BODEGA | +---------------------+ +-------------+ |
| +----------+ +----------+ |
| +------------------------+ |
| | inventory_uploads | |
| | inventory_col_mappings | |
| +------------------------+ |
| |
| === Lookup Tables === |
| countries, materials, shapes, position_part, reference_type, |
| manufacture_type, quality_tier |
| |
+-------------------------------------------------------------------------+
| | | |
v v v v
+-------------------+ +----------+ +-------------+ +----------------+
| login.html | | demo.html| | admin.html | | bodega.html |
| tienda.html | | (catalog)| | (CRUD + | | (inventory |
| pos.html | | | | users) | | upload/manage) |
| cuentas.html | | | | | | |
| captura.html | | | | | | |
+-------------------+ +----------+ +-------------+ +----------------+
Frontend: HTML/CSS/JS vanilla (sin framework)
Shared: nav.js, shared.css
```
---
## Database Schema Overview
### Tablas principales y sus relaciones
**Vehiculos (jerarquia):**
```
brands (id_brand, name_brand, country, region)
|
+--< models (id_model, brand_id, name_model, body_type)
|
+--< model_year_engine (id_mye, model_id, year_id, engine_id, trim_level)
| | |
| | +-- engines (id_engine, name_engine, displacement_cc, cylinders, power_hp)
| +-------- years (id_year, year_car)
|
+--< vehicle_parts (mye_id, part_id, quantity, position)
+--< vehicle_diagrams (mye_id, diagram_id)
```
**Partes (jerarquia):**
```
part_categories (id, name, parent_id) <-- arbol recursivo
|
+--< part_groups (id, category_id, name)
|
+--< parts (id_part, oem_part_number, name, group_id)
|
+--< aftermarket_parts (oem_part_id -> parts, manufacturer_id, part_number)
+--< part_cross_references (part_id -> parts, cross_reference_number, type)
+--< vehicle_parts (part_id -> parts, mye_id)
```
**SaaS / Multi-tenant:**
```
roles (id_rol: 1=ADMIN, 2=OWNER, 3=TALLER, 4=BODEGA)
|
+--< users (id_user, email, pass, id_rol, business_name, is_active)
|
+--< sessions (refresh_token, expires_at)
+--< warehouse_inventory (user_id, part_id, price, stock, location)
+--< inventory_uploads (user_id, filename, status, rows_imported)
+--< inventory_column_mappings (user_id, mapping JSON)
```
### Convenciones de nombres en PostgreSQL
| Tabla | PK | Naming pattern |
|-------|-----|---------------|
| brands | `id_brand` | `name_brand` |
| models | `id_model` | `name_model` |
| years | `id_year` | `year_car` |
| engines | `id_engine` | `name_engine` |
| model_year_engine | `id_mye` | Tabla pivote central |
| parts | `id_part` | `name_part`, `oem_part_number` |
| part_categories | `id_part_category` | `name_part_category` |
| part_groups | `id_part_group` | `name_part_group` |
| manufacturers | `id_manufacture` | `name_manufacture` |
---
## TecDoc Data Pipeline
Pipeline de 3 fases para importar datos de TecDoc (el catalogo europeo de autopartes).
```
Fase 1: DOWNLOAD Fase 2: IMPORT Fase 3: LINK
(import_tecdoc.py download) (import_tecdoc.py import) (link_vehicle_parts.py)
+------------------+ +------------------+ +------------------+
| Apify TecDoc | | JSON files | | parts + |
| Actor API | | data/tecdoc/ | | model_year_engine|
| $69/month | | | | |
| HTTP 201 = run | | manufacturers/ | | Genera links |
| | --------> | models/ | ---------> | masivos en |
| typeId=1 (cars) | JSON | vehicles/ | INSERT | vehicle_parts |
| langId=4 (EN) | files | | partes + | (12B+ filas) |
| countryId=153(MX)| | Resumable: | vehiculos | |
+------------------+ | skips existing | +------------------+
+------------------+
```
### Scripts adicionales
- **`import_tecdoc_parts.py`**: Importa partes OEM y aftermarket desde TecDoc
- **`import_live.py`**: Importa datos en tiempo real (sin bajar a JSON primero)
- **`migrate_aftermarket.py`**: Normaliza datos aftermarket a la estructura con `quality_tier`, `manufacturers`
### Filtros de importacion
- Se descartan variantes regionales (e.g., "TOYOTA (GAC)")
- `countryFilterId=153` limita a vehiculos vendidos en Mexico
- El script es resumable: si un JSON ya existe en `data/tecdoc/`, se salta
---
## Auth Flow
Flujo de autenticacion basado en JWT con refresh tokens.
```
Cliente Servidor PostgreSQL
| | |
| POST /api/auth/register | |
| {name, email, pass, role} | |
|------------------------------>| bcrypt.hash(pass) |
| |----------------------------->|
| | INSERT users (is_active=F) |
| 201 "pending activation" | |
|<------------------------------| |
| | |
| (Admin activa la cuenta) | |
| | |
| POST /api/auth/login | |
| {email, password} | |
|------------------------------>| SELECT users WHERE email |
| |<-----------------------------|
| | bcrypt.check(pass) |
| | Verify is_active=true |
| | |
| | jwt.encode(user_id, role, |
| | business_name, exp=15min) |
| | |
| | secrets.token_urlsafe(48) |
| | INSERT sessions |
| |----------------------------->|
| | |
| {access_token, refresh_token,| |
| user: {id, name, role}} | |
|<------------------------------| |
| | |
| GET /api/... (protected) | |
| Authorization: Bearer <AT> | |
|------------------------------>| jwt.decode(AT) |
| | Check role in allowed_roles |
| | Set g.user = {user_id, role}|
| 200 {data} | |
|<------------------------------| |
| | |
| (Access token expires) | |
| | |
| POST /api/auth/refresh | |
| {refresh_token} | |
|------------------------------>| SELECT sessions |
| |<-----------------------------|
| | Check expires_at > now() |
| | jwt.encode(new access_token)|
| {access_token} | |
|<------------------------------| |
```
### Roles y permisos
| Rol | ID | Permisos |
|-----|----|----------|
| `ADMIN` | 1 | Todo: gestionar usuarios, catalogo, inventario |
| `OWNER` | 2 | Gestionar usuarios, ver estadisticas |
| `TALLER` | 3 | Consultar catalogo, ver disponibilidad en bodegas |
| `BODEGA` | 4 | Gestionar inventario propio, subir CSV/Excel |
### JWT Token payload
```json
{
"user_id": 1,
"role": "TALLER",
"business_name": "Taller Perez",
"type": "access",
"exp": 1742300000,
"iat": 1742299100
}
```
---
## Inventory Flow (Bodega)
Flujo para que una bodega suba su inventario via CSV/Excel.
```
Bodega (Browser) Servidor PostgreSQL
| | |
| 1. PUT /api/inventory/mapping| |
| {part_number: "NUM_PARTE", | |
| price: "PRECIO", | |
| stock: "EXISTENCIA", | UPSERT inventory_col_map |
| location: "ALMACEN"} |----------------------------->|
|<------------------------------| |
| | |
| 2. POST /api/inventory/upload| |
| multipart/form-data: file | |
|------------------------------>| |
| | a) Read mapping |
| |<-----------------------------|
| | |
| | b) Parse CSV/Excel |
| | (openpyxl or csv module) |
| | |
| | c) For each row: |
| | - Extract part_number |
| | using mapping |
| | - Find part by OEM # |
| | - If not found, try |
| | aftermarket_parts |
| | - UPSERT warehouse_inv |
| |----------------------------->|
| | |
| {imported: 450, errors: 12} | d) Update inventory_uploads |
|<------------------------------|----------------------------->|
```
### Tabla `warehouse_inventory`
```sql
warehouse_inventory (
id_inventory BIGSERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users,
part_id INTEGER REFERENCES parts,
price NUMERIC(12,2),
stock_quantity INTEGER DEFAULT 0,
warehouse_location VARCHAR(100) DEFAULT 'Principal',
updated_at TIMESTAMP,
UNIQUE(user_id, part_id, warehouse_location)
)
```
El UNIQUE constraint permite que una bodega tenga la misma parte en multiples ubicaciones (e.g., "Principal", "Sucursal Norte").
---
## Aftermarket Linking
Como se relacionan partes OEM con alternativas aftermarket y cross-references.
```
+--------------------+
| parts |
| (OEM catalog) |
| id_part: 500 |
| oem_part_number: |
| "04152-YZZA1" |
+--------+-----------+
|
+--------------+--------------+
| |
v v
+----------------------------+ +----------------------------+
| aftermarket_parts | | part_cross_references |
| (alternativas) | | (numeros alternos) |
| | | |
| oem_part_id: 500 | | part_id: 500 |
| manufacturer_id: -> DENSO | | cross_reference_number: |
| part_number: "DXP-1234" | | "90915-YZZF1" |
| quality_tier: "premium" | | reference_type: |
| price_usd: 12.50 | | "oem_alternate" |
| warranty_months: 24 | | source: "TecDoc" |
+----------------------------+ +----------------------------+
```
### Tipos de cross-reference
| Tipo | Descripcion |
|------|-------------|
| `oem_alternate` | Otro numero OEM para la misma parte (mismo fabricante) |
| `supersession` | La parte fue reemplazada por un numero nuevo |
| `interchange` | Numero de un competidor que es equivalente |
| `competitor` | Referencia cruzada a otra marca |
### Flujo de busqueda por numero de parte
Cuando un taller busca un numero de parte, el sistema busca en 3 lugares:
1. **`parts.oem_part_number`** - Match directo OEM
2. **`aftermarket_parts.part_number`** - Match en parte aftermarket, retorna el OEM original
3. **`part_cross_references.cross_reference_number`** - Match en cross-ref, retorna el OEM original
Esto permite que el taller encuentre la parte sin importar que numero use (OEM, aftermarket, o numero alterno).
### Calidad (quality_tier)
| Tier | Descripcion | Ejemplo |
|------|-------------|---------|
| `economy` | Precio bajo, calidad basica | Marcas genericas |
| `standard` | Calidad media, buen balance | Monroe, Moog |
| `premium` | Calidad alta, cercana a OEM | Denso, Bosch |
---
## Stack Frontend
El frontend es HTML/CSS/JS vanilla sin framework. Cada pagina es independiente.
```
dashboard/
+-- shared.css # Estilos compartidos (colores, layout, cards)
+-- nav.js # Navegacion compartida (inyecta sidebar/header)
+-- login.html + .css + .js
+-- demo.html # Catalogo publico
+-- admin.html + .js # Panel admin (CRUD, users, import/export)
+-- bodega.html + .css + .js # Inventory management
+-- tienda.html + .css + .js # Store view para talleres
+-- pos.html + .css + .js # Point of sale
+-- captura.html + .css + .js # Captura de partes
+-- cuentas.html + .css + .js # Gestion de cuentas
+-- dashboard.js # Logica del catalogo principal
+-- enhanced-search.js # Busqueda avanzada
```
### Patron de comunicacion con API
Todas las paginas usan `fetch()` con el patron:
```javascript
const token = localStorage.getItem('access_token');
const res = await fetch('/api/...', {
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await res.json();
```
### Nota sobre NUMERIC de PostgreSQL
PostgreSQL retorna valores `NUMERIC` como strings. Todas las funciones de formato en JS usan `parseFloat()` para convertir antes de mostrar.

View File

@@ -0,0 +1,84 @@
# Captura de Partes OEM — Diseño
## Resumen
App web de captura de datos para 3 capturistas que trabajan en pipeline:
1. **Capturista OEM** — registra partes OEM por vehículo
2. **Capturista Intercambios** — agrega aftermarket por pieza OEM
3. **Capturista Imágenes** — sube fotos por pieza OEM
## Arquitectura
- Frontend: HTML/CSS/JS vanilla (una sola página con 3 tabs)
- Backend: API Flask existente en `server.py` (endpoints `/api/admin/*`)
- Base de datos: PostgreSQL `nexus_autoparts`
- Almacenamiento imágenes: `/home/Autopartes/dashboard/static/parts/`
## Sección 1: Captura OEM
### Flujo
1. Capturista ve lista de vehículos pendientes (sin partes OEM)
2. Filtra por marca/modelo, elige un vehículo
3. Ve tabla con 12 categorías / 63 grupos
4. Por cada grupo, puede [+ Agregar pieza]: # OEM, nombre, cantidad
5. Guarda fila por fila (POST /api/admin/parts + POST /api/admin/fitment)
6. Marca vehículo como "Terminado" → desaparece de pendientes
### Estado de vehículo
- **Pendiente**: 0 partes registradas
- **En progreso**: tiene partes pero no marcado terminado
- **Terminado**: marcado explícitamente por capturista
### Lógica de guardado
1. POST /api/admin/parts → crea pieza OEM → obtiene part_id
2. POST /api/admin/fitment → vincula pieza a vehículo (mye_id + part_id)
3. Si OEM ya existe en DB, reutilizar part_id existente
## Sección 2: Captura Intercambios
### Flujo
1. Ve lista de piezas OEM sin aftermarket
2. Selecciona una pieza → ve su info OEM
3. Agrega intercambios: fabricante, # aftermarket, calidad, precio, garantía
4. POST /api/admin/aftermarket
5. Siguiente pieza
### Campos por intercambio
- manufacturer_id (dropdown de fabricantes)
- part_number (texto)
- name (texto)
- quality_tier (economy/standard/oem/premium)
- price_usd (número)
- warranty_months (número)
## Sección 3: Captura Imágenes
### Flujo
1. Ve lista de piezas OEM sin imagen
2. Selecciona una pieza → ve su info
3. Sube archivo de imagen (jpg/png/webp)
4. Se guarda en /static/parts/{oem_number}.{ext}
5. Se actualiza campo image_url en tabla parts
### Restricciones
- Máximo 2MB por imagen
- Formatos: jpg, png, webp
- Se redimensiona a 800x800 max en el servidor (si es necesario)
## Nuevos endpoints necesarios
### Estado de vehículos
- GET /api/captura/vehicles/pending — vehículos sin partes
- GET /api/captura/vehicles/in-progress — con partes pero no terminados
- POST /api/captura/vehicles/{mye_id}/complete — marcar terminado
### Piezas para intercambios
- GET /api/captura/parts/without-aftermarket — piezas sin intercambio
### Piezas para imágenes
- GET /api/captura/parts/without-image — piezas sin foto
- POST /api/captura/parts/{part_id}/image — subir imagen
## Archivos
- `/home/Autopartes/dashboard/captura.html` — página principal
- `/home/Autopartes/dashboard/captura.js` — lógica de las 3 secciones
- `/home/Autopartes/dashboard/captura.css` — estilos específicos
- Ruta en server.py: `/captura` → sirve captura.html

View File

@@ -0,0 +1,130 @@
# Punto de Venta + Cuentas por Cobrar — Diseño
## Resumen
Sistema de punto de venta integrado al catálogo Nexus Autoparts con:
- Ventas de partes OEM y aftermarket
- Facturación con datos fiscales (RFC, IVA, folio consecutivo)
- Cuentas a crédito para clientes frecuentes
- Pagos: abonos parciales y pago al corte
- Precios calculados por costo + margen configurable
## Tablas nuevas
### customers
| Columna | Tipo | Descripción |
|---------|------|-------------|
| id_customer | SERIAL PK | |
| name | VARCHAR(200) | Nombre del cliente |
| rfc | VARCHAR(13) | RFC fiscal |
| business_name | VARCHAR(300) | Razón social |
| email | VARCHAR(200) | |
| phone | VARCHAR(20) | |
| address | TEXT | Dirección fiscal |
| credit_limit | DECIMAL(12,2) | Límite de crédito |
| balance | DECIMAL(12,2) DEFAULT 0 | Saldo actual (lo que debe) |
| payment_terms | INTEGER DEFAULT 30 | Días de crédito |
| active | BOOLEAN DEFAULT TRUE | |
| created_at | TIMESTAMP DEFAULT NOW() | |
### invoices
| Columna | Tipo | Descripción |
|---------|------|-------------|
| id_invoice | SERIAL PK | |
| customer_id | INTEGER FK customers | |
| folio | VARCHAR(20) UNIQUE | Folio consecutivo (ej: NX-000001) |
| date_issued | TIMESTAMP DEFAULT NOW() | Fecha emisión |
| subtotal | DECIMAL(12,2) | Sin IVA |
| tax_rate | DECIMAL(5,4) DEFAULT 0.16 | Tasa IVA |
| tax_amount | DECIMAL(12,2) | Monto IVA |
| total | DECIMAL(12,2) | Total con IVA |
| amount_paid | DECIMAL(12,2) DEFAULT 0 | Total abonado |
| status | VARCHAR(20) DEFAULT 'pending' | pending/partial/paid/cancelled |
| notes | TEXT | |
| created_at | TIMESTAMP DEFAULT NOW() | |
### invoice_items
| Columna | Tipo | Descripción |
|---------|------|-------------|
| id_invoice_item | SERIAL PK | |
| invoice_id | INTEGER FK invoices | |
| part_id | INTEGER FK parts (nullable) | Pieza OEM |
| aftermarket_id | INTEGER FK aftermarket_parts (nullable) | Pieza aftermarket |
| description | VARCHAR(500) | Descripción de la línea |
| quantity | INTEGER DEFAULT 1 | |
| unit_cost | DECIMAL(12,2) | Costo unitario |
| margin_pct | DECIMAL(5,2) | Margen % aplicado |
| unit_price | DECIMAL(12,2) | Precio de venta unitario |
| line_total | DECIMAL(12,2) | quantity * unit_price |
### payments
| Columna | Tipo | Descripción |
|---------|------|-------------|
| id_payment | SERIAL PK | |
| customer_id | INTEGER FK customers | |
| invoice_id | INTEGER FK invoices (nullable) | Si aplica a factura específica |
| amount | DECIMAL(12,2) | Monto del pago |
| payment_method | VARCHAR(20) | efectivo/transferencia/cheque/tarjeta |
| reference | VARCHAR(100) | # referencia del pago |
| date_payment | TIMESTAMP DEFAULT NOW() | |
| notes | TEXT | |
| created_at | TIMESTAMP DEFAULT NOW() | |
## Columnas nuevas en tablas existentes
### parts
- `cost_usd DECIMAL(12,2)` — costo de la pieza
### aftermarket_parts
- `cost_usd DECIMAL(12,2)` — costo de la pieza aftermarket
## Configuración
- Margen default: 30% (configurable)
- IVA: 16%
- Folio format: NX-XXXXXX (consecutivo)
## Páginas web nuevas
### /pos — Punto de Venta
- Selector de cliente (buscador + crear nuevo)
- Buscador de partes (OEM y aftermarket)
- Carrito con líneas editables (costo, margen, precio)
- Botón facturar → genera factura con folio
### /cuentas — Cuentas por Cobrar
- Lista de clientes con saldos
- Detalle de cliente: facturas pendientes, historial pagos
- Registrar pago/abono
- Estado de cuenta imprimible
## Endpoints API nuevos
### Clientes
- GET /api/pos/customers — listar clientes
- GET /api/pos/customers/:id — detalle cliente con saldo
- POST /api/pos/customers — crear cliente
- PUT /api/pos/customers/:id — editar cliente
### Facturas
- GET /api/pos/invoices — listar facturas (filtros: cliente, status, fecha)
- GET /api/pos/invoices/:id — detalle factura con líneas
- POST /api/pos/invoices — crear factura (con líneas)
- PUT /api/pos/invoices/:id/cancel — cancelar factura
### Pagos
- GET /api/pos/payments — listar pagos
- POST /api/pos/payments — registrar pago/abono
- GET /api/pos/customers/:id/statement — estado de cuenta
## Flujos
### Venta
1. Seleccionar/crear cliente
2. Buscar partes → agregar al carrito
3. Ajustar margen si necesario
4. Facturar → se crea invoice + items, se suma al balance del cliente
### Pago
1. Buscar cliente → ver saldo y facturas pendientes
2. Registrar pago (monto, método, referencia)
3. Se aplica a factura o como abono general
4. Se actualiza balance del cliente

View File

@@ -0,0 +1,722 @@
# POS + Cuentas por Cobrar — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add a Point of Sale with credit accounts, invoicing with tax data, and payment tracking to the Nexus Autoparts system.
**Architecture:** New PostgreSQL tables (customers, invoices, invoice_items, payments) + API endpoints in server.py + two new pages (pos.html, cuentas.html). Prices are cost + configurable margin. Customer balances are maintained via triggers on invoice/payment inserts.
**Tech Stack:** Flask, PostgreSQL, SQLAlchemy raw SQL via `text()`, vanilla HTML/CSS/JS (same stack as existing app).
---
### Task 1: Database schema — Create new tables and columns
**Files:**
- Modify: `/home/Autopartes/dashboard/server.py` (no changes yet, just DB)
**Step 1: Add cost_usd columns and create POS tables**
Run this SQL via Python script:
```python
# /home/Autopartes/setup_pos_tables.py
from sqlalchemy import create_engine, text
import sys
sys.path.insert(0, '.')
from config import DB_URL
engine = create_engine(DB_URL)
with engine.connect() as conn:
conn.execute(text("""
-- Add cost columns
ALTER TABLE parts ADD COLUMN IF NOT EXISTS cost_usd DECIMAL(12,2);
ALTER TABLE aftermarket_parts ADD COLUMN IF NOT EXISTS cost_usd DECIMAL(12,2);
-- Customers
CREATE TABLE IF NOT EXISTS customers (
id_customer SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
rfc VARCHAR(13),
business_name VARCHAR(300),
email VARCHAR(200),
phone VARCHAR(20),
address TEXT,
credit_limit DECIMAL(12,2) DEFAULT 0,
balance DECIMAL(12,2) DEFAULT 0,
payment_terms INTEGER DEFAULT 30,
active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW()
);
-- Invoices
CREATE TABLE IF NOT EXISTS invoices (
id_invoice SERIAL PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(id_customer),
folio VARCHAR(20) UNIQUE NOT NULL,
date_issued TIMESTAMP DEFAULT NOW(),
subtotal DECIMAL(12,2) NOT NULL DEFAULT 0,
tax_rate DECIMAL(5,4) DEFAULT 0.16,
tax_amount DECIMAL(12,2) NOT NULL DEFAULT 0,
total DECIMAL(12,2) NOT NULL DEFAULT 0,
amount_paid DECIMAL(12,2) DEFAULT 0,
status VARCHAR(20) DEFAULT 'pending',
notes TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
-- Invoice items
CREATE TABLE IF NOT EXISTS invoice_items (
id_invoice_item SERIAL PRIMARY KEY,
invoice_id INTEGER NOT NULL REFERENCES invoices(id_invoice) ON DELETE CASCADE,
part_id INTEGER REFERENCES parts(id_part),
aftermarket_id INTEGER REFERENCES aftermarket_parts(id_aftermarket_parts),
description VARCHAR(500) NOT NULL,
quantity INTEGER DEFAULT 1,
unit_cost DECIMAL(12,2) DEFAULT 0,
margin_pct DECIMAL(5,2) DEFAULT 30,
unit_price DECIMAL(12,2) NOT NULL,
line_total DECIMAL(12,2) NOT NULL
);
-- Payments
CREATE TABLE IF NOT EXISTS payments (
id_payment SERIAL PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(id_customer),
invoice_id INTEGER REFERENCES invoices(id_invoice),
amount DECIMAL(12,2) NOT NULL,
payment_method VARCHAR(20) NOT NULL DEFAULT 'efectivo',
reference VARCHAR(100),
date_payment TIMESTAMP DEFAULT NOW(),
notes TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_invoices_customer ON invoices(customer_id);
CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status);
CREATE INDEX IF NOT EXISTS idx_invoices_folio ON invoices(folio);
CREATE INDEX IF NOT EXISTS idx_invoice_items_invoice ON invoice_items(invoice_id);
CREATE INDEX IF NOT EXISTS idx_payments_customer ON payments(customer_id);
CREATE INDEX IF NOT EXISTS idx_payments_invoice ON payments(invoice_id);
-- Folio sequence
CREATE SEQUENCE IF NOT EXISTS invoice_folio_seq START 1;
"""))
conn.commit()
print("POS tables created successfully")
```
**Step 2: Run the script**
```bash
cd /home/Autopartes && python3 setup_pos_tables.py
```
**Step 3: Verify**
```bash
python3 -c "
from sqlalchemy import create_engine, text
from config import DB_URL
engine = create_engine(DB_URL)
with engine.connect() as conn:
for t in ['customers','invoices','invoice_items','payments']:
cols = conn.execute(text(f\"SELECT column_name FROM information_schema.columns WHERE table_name='{t}' ORDER BY ordinal_position\")).fetchall()
print(f'{t}: {[c[0] for c in cols]}')
"
```
**Step 4: Commit**
```bash
git add setup_pos_tables.py && git commit -m "feat(pos): add POS database schema"
```
---
### Task 2: API endpoints — Customers CRUD
**Files:**
- Modify: `/home/Autopartes/dashboard/server.py` — insert before Main Block (line ~2672)
**Step 1: Add customer endpoints**
Insert these endpoints before the `# Main Block` comment in server.py:
```python
# ============================================================================
# POS (Point of Sale) Endpoints
# ============================================================================
@app.route('/pos')
def pos_page():
return send_from_directory('.', 'pos.html')
@app.route('/pos.js')
def pos_js():
return send_from_directory('.', 'pos.js')
@app.route('/pos.css')
def pos_css():
return send_from_directory('.', 'pos.css')
@app.route('/cuentas')
def cuentas_page():
return send_from_directory('.', 'cuentas.html')
@app.route('/cuentas.js')
def cuentas_js():
return send_from_directory('.', 'cuentas.js')
@app.route('/cuentas.css')
def cuentas_css():
return send_from_directory('.', 'cuentas.css')
# ---- Customers ----
@app.route('/api/pos/customers')
def api_pos_customers():
session = Session()
try:
search = request.args.get('search', '')
page = int(request.args.get('page', 1))
per_page = min(int(request.args.get('per_page', 50)), 100)
offset = (page - 1) * per_page
filters = ["active = TRUE"]
params = {'limit': per_page, 'offset': offset}
if search:
filters.append("(name ILIKE :search OR rfc ILIKE :search OR business_name ILIKE :search)")
params['search'] = f'%{search}%'
where = ' AND '.join(filters)
total = session.execute(text(f"SELECT COUNT(*) FROM customers WHERE {where}"), params).scalar()
rows = session.execute(text(f"""
SELECT id_customer, name, rfc, business_name, phone, balance, credit_limit, payment_terms
FROM customers WHERE {where}
ORDER BY name LIMIT :limit OFFSET :offset
"""), params).mappings().all()
return jsonify({'data': [dict(r) for r in rows], 'pagination': {
'page': page, 'per_page': per_page, 'total': total,
'total_pages': (total + per_page - 1) // per_page
}})
finally:
session.close()
@app.route('/api/pos/customers/<int:customer_id>')
def api_pos_customer_detail(customer_id):
session = Session()
try:
row = session.execute(text(
"SELECT * FROM customers WHERE id_customer = :id"
), {'id': customer_id}).mappings().first()
if not row:
return jsonify({'error': 'Cliente no encontrado'}), 404
return jsonify(dict(row))
finally:
session.close()
@app.route('/api/pos/customers', methods=['POST'])
def api_pos_create_customer():
session = Session()
try:
data = request.get_json()
result = session.execute(text("""
INSERT INTO customers (name, rfc, business_name, email, phone, address, credit_limit, payment_terms)
VALUES (:name, :rfc, :business_name, :email, :phone, :address, :credit_limit, :payment_terms)
RETURNING id_customer
"""), {
'name': data['name'], 'rfc': data.get('rfc'),
'business_name': data.get('business_name'),
'email': data.get('email'), 'phone': data.get('phone'),
'address': data.get('address'),
'credit_limit': data.get('credit_limit', 0),
'payment_terms': data.get('payment_terms', 30)
})
new_id = result.scalar()
session.commit()
return jsonify({'id': new_id, 'message': 'Cliente creado'})
except Exception as e:
session.rollback()
return jsonify({'error': str(e)}), 500
finally:
session.close()
@app.route('/api/pos/customers/<int:customer_id>', methods=['PUT'])
def api_pos_update_customer(customer_id):
session = Session()
try:
data = request.get_json()
session.execute(text("""
UPDATE customers SET name = :name, rfc = :rfc, business_name = :business_name,
email = :email, phone = :phone, address = :address,
credit_limit = :credit_limit, payment_terms = :payment_terms
WHERE id_customer = :id
"""), {
'name': data['name'], 'rfc': data.get('rfc'),
'business_name': data.get('business_name'),
'email': data.get('email'), 'phone': data.get('phone'),
'address': data.get('address'),
'credit_limit': data.get('credit_limit', 0),
'payment_terms': data.get('payment_terms', 30),
'id': customer_id
})
session.commit()
return jsonify({'message': 'Cliente actualizado'})
except Exception as e:
session.rollback()
return jsonify({'error': str(e)}), 500
finally:
session.close()
```
**Step 2: Verify routes load**
```bash
cd /home/Autopartes/dashboard && python3 -c "import server; [print(r.rule) for r in server.app.url_map.iter_rules() if 'pos' in r.rule]"
```
**Step 3: Commit**
```bash
git add dashboard/server.py && git commit -m "feat(pos): add customer CRUD endpoints"
```
---
### Task 3: API endpoints — Invoices and invoice items
**Files:**
- Modify: `/home/Autopartes/dashboard/server.py`
**Step 1: Add invoice endpoints** (insert after customer endpoints, before Main Block)
```python
# ---- Invoices ----
@app.route('/api/pos/invoices')
def api_pos_invoices():
session = Session()
try:
customer_id = request.args.get('customer_id', '')
status = request.args.get('status', '')
page = int(request.args.get('page', 1))
per_page = min(int(request.args.get('per_page', 50)), 100)
offset = (page - 1) * per_page
filters = ["1=1"]
params = {'limit': per_page, 'offset': offset}
if customer_id:
filters.append("i.customer_id = :customer_id")
params['customer_id'] = int(customer_id)
if status:
filters.append("i.status = :status")
params['status'] = status
where = ' AND '.join(filters)
total = session.execute(text(f"""
SELECT COUNT(*) FROM invoices i WHERE {where}
"""), params).scalar()
rows = session.execute(text(f"""
SELECT i.id_invoice, i.folio, i.date_issued, i.subtotal, i.tax_amount,
i.total, i.amount_paid, i.status, c.name AS customer_name, c.rfc
FROM invoices i
JOIN customers c ON i.customer_id = c.id_customer
WHERE {where}
ORDER BY i.date_issued DESC
LIMIT :limit OFFSET :offset
"""), params).mappings().all()
return jsonify({'data': [dict(r) for r in rows], 'pagination': {
'page': page, 'per_page': per_page, 'total': total,
'total_pages': (total + per_page - 1) // per_page
}})
finally:
session.close()
@app.route('/api/pos/invoices/<int:invoice_id>')
def api_pos_invoice_detail(invoice_id):
session = Session()
try:
inv = session.execute(text("""
SELECT i.*, c.name AS customer_name, c.rfc, c.business_name, c.address
FROM invoices i JOIN customers c ON i.customer_id = c.id_customer
WHERE i.id_invoice = :id
"""), {'id': invoice_id}).mappings().first()
if not inv:
return jsonify({'error': 'Factura no encontrada'}), 404
items = session.execute(text("""
SELECT ii.*, p.oem_part_number, ap.part_number AS aftermarket_number
FROM invoice_items ii
LEFT JOIN parts p ON ii.part_id = p.id_part
LEFT JOIN aftermarket_parts ap ON ii.aftermarket_id = ap.id_aftermarket_parts
WHERE ii.invoice_id = :id
ORDER BY ii.id_invoice_item
"""), {'id': invoice_id}).mappings().all()
return jsonify({'invoice': dict(inv), 'items': [dict(it) for it in items]})
finally:
session.close()
@app.route('/api/pos/invoices', methods=['POST'])
def api_pos_create_invoice():
session = Session()
try:
data = request.get_json()
customer_id = data['customer_id']
items = data['items'] # [{part_id, aftermarket_id, description, quantity, unit_cost, margin_pct, unit_price}]
tax_rate = data.get('tax_rate', 0.16)
notes = data.get('notes', '')
if not items:
return jsonify({'error': 'La factura debe tener al menos una línea'}), 400
# Generate folio
folio_num = session.execute(text("SELECT nextval('invoice_folio_seq')")).scalar()
folio = f"NX-{folio_num:06d}"
# Calculate totals
subtotal = sum(it['quantity'] * it['unit_price'] for it in items)
tax_amount = round(subtotal * tax_rate, 2)
total = round(subtotal + tax_amount, 2)
# Create invoice
result = session.execute(text("""
INSERT INTO invoices (customer_id, folio, subtotal, tax_rate, tax_amount, total, notes)
VALUES (:customer_id, :folio, :subtotal, :tax_rate, :tax_amount, :total, :notes)
RETURNING id_invoice
"""), {
'customer_id': customer_id, 'folio': folio,
'subtotal': subtotal, 'tax_rate': tax_rate,
'tax_amount': tax_amount, 'total': total, 'notes': notes
})
invoice_id = result.scalar()
# Create items
for it in items:
line_total = it['quantity'] * it['unit_price']
session.execute(text("""
INSERT INTO invoice_items (invoice_id, part_id, aftermarket_id, description,
quantity, unit_cost, margin_pct, unit_price, line_total)
VALUES (:inv_id, :part_id, :af_id, :desc, :qty, :cost, :margin, :price, :total)
"""), {
'inv_id': invoice_id,
'part_id': it.get('part_id'),
'af_id': it.get('aftermarket_id'),
'desc': it['description'],
'qty': it['quantity'],
'cost': it.get('unit_cost', 0),
'margin': it.get('margin_pct', 30),
'price': it['unit_price'],
'total': line_total
})
# Update customer balance
session.execute(text(
"UPDATE customers SET balance = balance + :total WHERE id_customer = :id"
), {'total': total, 'id': customer_id})
session.commit()
return jsonify({'id': invoice_id, 'folio': folio, 'total': total, 'message': 'Factura creada'})
except Exception as e:
session.rollback()
return jsonify({'error': str(e)}), 500
finally:
session.close()
@app.route('/api/pos/invoices/<int:invoice_id>/cancel', methods=['PUT'])
def api_pos_cancel_invoice(invoice_id):
session = Session()
try:
inv = session.execute(text(
"SELECT total, customer_id, status FROM invoices WHERE id_invoice = :id"
), {'id': invoice_id}).mappings().first()
if not inv:
return jsonify({'error': 'Factura no encontrada'}), 404
if inv['status'] == 'cancelled':
return jsonify({'error': 'La factura ya está cancelada'}), 400
session.execute(text(
"UPDATE invoices SET status = 'cancelled' WHERE id_invoice = :id"
), {'id': invoice_id})
# Reverse the balance
session.execute(text(
"UPDATE customers SET balance = balance - :total WHERE id_customer = :cid"
), {'total': inv['total'], 'cid': inv['customer_id']})
session.commit()
return jsonify({'message': 'Factura cancelada'})
except Exception as e:
session.rollback()
return jsonify({'error': str(e)}), 500
finally:
session.close()
```
**Step 2: Commit**
```bash
git add dashboard/server.py && git commit -m "feat(pos): add invoice endpoints"
```
---
### Task 4: API endpoints — Payments and statements
**Files:**
- Modify: `/home/Autopartes/dashboard/server.py`
**Step 1: Add payment endpoints** (insert after invoice endpoints)
```python
# ---- Payments ----
@app.route('/api/pos/payments', methods=['POST'])
def api_pos_create_payment():
session = Session()
try:
data = request.get_json()
customer_id = data['customer_id']
amount = float(data['amount'])
payment_method = data.get('payment_method', 'efectivo')
reference = data.get('reference')
invoice_id = data.get('invoice_id')
notes = data.get('notes')
if amount <= 0:
return jsonify({'error': 'El monto debe ser mayor a 0'}), 400
result = session.execute(text("""
INSERT INTO payments (customer_id, invoice_id, amount, payment_method, reference, notes)
VALUES (:cid, :inv_id, :amount, :method, :ref, :notes)
RETURNING id_payment
"""), {
'cid': customer_id, 'inv_id': invoice_id,
'amount': amount, 'method': payment_method,
'ref': reference, 'notes': notes
})
payment_id = result.scalar()
# Update customer balance
session.execute(text(
"UPDATE customers SET balance = balance - :amount WHERE id_customer = :id"
), {'amount': amount, 'id': customer_id})
# If applied to specific invoice, update its amount_paid and status
if invoice_id:
session.execute(text(
"UPDATE invoices SET amount_paid = amount_paid + :amount WHERE id_invoice = :id"
), {'amount': amount, 'id': invoice_id})
# Update invoice status
session.execute(text("""
UPDATE invoices SET status = CASE
WHEN amount_paid >= total THEN 'paid'
WHEN amount_paid > 0 THEN 'partial'
ELSE 'pending'
END WHERE id_invoice = :id
"""), {'id': invoice_id})
session.commit()
return jsonify({'id': payment_id, 'message': 'Pago registrado'})
except Exception as e:
session.rollback()
return jsonify({'error': str(e)}), 500
finally:
session.close()
@app.route('/api/pos/customers/<int:customer_id>/statement')
def api_pos_customer_statement(customer_id):
session = Session()
try:
customer = session.execute(text(
"SELECT * FROM customers WHERE id_customer = :id"
), {'id': customer_id}).mappings().first()
if not customer:
return jsonify({'error': 'Cliente no encontrado'}), 404
invoices = session.execute(text("""
SELECT id_invoice, folio, date_issued, total, amount_paid, status
FROM invoices WHERE customer_id = :id AND status != 'cancelled'
ORDER BY date_issued DESC LIMIT 100
"""), {'id': customer_id}).mappings().all()
payments = session.execute(text("""
SELECT p.id_payment, p.amount, p.payment_method, p.reference,
p.date_payment, p.notes, i.folio AS invoice_folio
FROM payments p
LEFT JOIN invoices i ON p.invoice_id = i.id_invoice
WHERE p.customer_id = :id
ORDER BY p.date_payment DESC LIMIT 100
"""), {'id': customer_id}).mappings().all()
return jsonify({
'customer': dict(customer),
'invoices': [dict(i) for i in invoices],
'payments': [dict(p) for p in payments]
})
finally:
session.close()
@app.route('/api/pos/search-parts')
def api_pos_search_parts():
"""Search parts for the POS cart — returns OEM and aftermarket with prices."""
session = Session()
try:
q = request.args.get('q', '')
if len(q) < 2:
return jsonify([])
results = []
# Search OEM parts
oem = session.execute(text("""
SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es,
p.cost_usd, pg.name_part_group AS group_name,
'oem' AS part_type
FROM parts p
JOIN part_groups pg ON p.group_id = pg.id_part_group
WHERE p.oem_part_number ILIKE :q OR p.name_part ILIKE :q
ORDER BY p.oem_part_number LIMIT 20
"""), {'q': f'%{q}%'}).mappings().all()
results.extend([dict(r) for r in oem])
# Search aftermarket parts
af = session.execute(text("""
SELECT ap.id_aftermarket_parts AS id_part, ap.part_number AS oem_part_number,
ap.name_aftermarket_parts AS name_part, ap.name_es,
COALESCE(ap.cost_usd, ap.price_usd) AS cost_usd,
m.name_manufacture AS group_name,
'aftermarket' AS part_type
FROM aftermarket_parts ap
JOIN manufacturers m ON ap.manufacturer_id = m.id_manufacture
WHERE ap.part_number ILIKE :q OR ap.name_aftermarket_parts ILIKE :q
ORDER BY ap.part_number LIMIT 20
"""), {'q': f'%{q}%'}).mappings().all()
results.extend([dict(r) for r in af])
return jsonify(results)
finally:
session.close()
```
**Step 2: Commit**
```bash
git add dashboard/server.py && git commit -m "feat(pos): add payment and search endpoints"
```
---
### Task 5: Frontend — POS page (pos.html + pos.css + pos.js)
**Files:**
- Create: `/home/Autopartes/dashboard/pos.html`
- Create: `/home/Autopartes/dashboard/pos.css`
- Create: `/home/Autopartes/dashboard/pos.js`
**Step 1: Create pos.html**
HTML page with:
- Customer selector (search + create new)
- Part search bar (searches OEM + aftermarket)
- Cart table (description, qty, cost, margin%, price, total)
- Totals section (subtotal, IVA 16%, total)
- "Facturar" button
**Step 2: Create pos.css**
Styles for the POS layout: 2-column (left=search+cart, right=customer info + totals).
**Step 3: Create pos.js**
JavaScript logic:
- Customer search and selection
- Part search → add to cart
- Editable margin per line
- Auto-calculate prices: `unit_price = cost * (1 + margin/100)`
- Totals: subtotal, IVA, total
- Facturar → POST /api/pos/invoices
**Step 4: Commit**
```bash
git add dashboard/pos.html dashboard/pos.css dashboard/pos.js
git commit -m "feat(pos): add point of sale frontend"
```
---
### Task 6: Frontend — Cuentas page (cuentas.html + cuentas.css + cuentas.js)
**Files:**
- Create: `/home/Autopartes/dashboard/cuentas.html`
- Create: `/home/Autopartes/dashboard/cuentas.css`
- Create: `/home/Autopartes/dashboard/cuentas.js`
**Step 1: Create cuentas.html**
HTML page with:
- Customer list with balances
- Customer detail: info card, pending invoices, payment history
- Payment form: amount, method, reference, apply to invoice
- Create/edit customer modal
**Step 2: Create cuentas.js**
JavaScript logic:
- Load customers with balances
- Customer detail view with statement
- Register payment → POST /api/pos/payments
- Create/edit customer form
**Step 3: Commit**
```bash
git add dashboard/cuentas.html dashboard/cuentas.css dashboard/cuentas.js
git commit -m "feat(pos): add accounts receivable frontend"
```
---
### Task 7: Navigation + final integration
**Files:**
- Modify: `/home/Autopartes/dashboard/nav.js`
**Step 1: Add POS and Cuentas links to nav**
Add to the `navLinks` array and `isActive` function:
```javascript
// isActive:
if ((h === '/pos') && (p === '/pos')) return true;
if ((h === '/cuentas') && (p === '/cuentas')) return true;
// navLinks:
{ label: 'POS', href: '/pos' },
{ label: 'Cuentas', href: '/cuentas' },
```
**Step 2: Test full flow**
```bash
# Start server
nohup python3 /home/Autopartes/dashboard/server.py > /tmp/nexus-server.log 2>&1 &
sleep 2
# Test customer creation
curl -s -X POST http://localhost:5000/api/pos/customers \
-H "Content-Type: application/json" \
-d '{"name":"Taller Prueba","rfc":"TAL123456XX0","credit_limit":50000,"payment_terms":30}'
# Test page loads
curl -s -o /dev/null -w "%{http_code}" http://localhost:5000/pos
curl -s -o /dev/null -w "%{http_code}" http://localhost:5000/cuentas
```
**Step 3: Final commit**
```bash
git add -A && git commit -m "feat(pos): complete POS and accounts system"
```

View File

@@ -0,0 +1,242 @@
# Nexus Autoparts — SaaS + Aftermarket Design
**Date:** 2026-03-15
**Status:** Approved
## Overview
Two features for Nexus Autoparts:
1. **SaaS user system** — auth, roles (ADMIN, OWNER, TALLER, BODEGA), warehouse inventory uploads with flexible column mapping, availability/pricing visible to authenticated talleres.
2. **Aftermarket parts cleanup** — migrate 357K AFT- prefixed parts from `parts` table into `aftermarket_parts`, link to OEM parts, fix import pipeline.
Architecture: **Monolith approach** — everything in the existing PostgreSQL DB and Flask backend.
---
## Section 1: Authentication & Users
### Tables
**`roles`** (existing, update names):
| id_rol | name_rol |
|--------|----------|
| 1 | ADMIN |
| 2 | OWNER |
| 3 | TALLER |
| 4 | BODEGA |
**`users`** (existing, extend):
```
id_user (PK)
name_user (VARCHAR 200)
email (VARCHAR 200, UNIQUE)
pass (VARCHAR 200, bcrypt hash)
id_rol (FK → roles)
business_name (VARCHAR 200)
phone (VARCHAR 50)
address (TEXT)
is_active (BOOLEAN DEFAULT false)
created_at (TIMESTAMP DEFAULT now())
last_login (TIMESTAMP)
```
**`sessions`** (new):
```
id_session (PK)
user_id (FK → users)
refresh_token (VARCHAR 500, UNIQUE)
expires_at (TIMESTAMP)
created_at (TIMESTAMP DEFAULT now())
```
### Auth Flow
- Login: `POST /api/auth/login` → JWT access token (15 min) + refresh token (30 days)
- Refresh: `POST /api/auth/refresh` → new access token
- JWT payload: `user_id`, `role`, `business_name`
- Public endpoints: catalog, landing, search, aftermarket list
- Protected endpoints: pricing, inventory, admin, POS
- Flask middleware validates JWT on protected routes
### Registration
- Bodegas and talleres register via form
- ADMIN approves accounts (is_active = false by default)
- ADMIN can create accounts directly
---
## Section 2: Warehouse Inventory
### Tables
**`warehouse_inventory`** (new):
```
id_inventory (PK, BIGINT)
user_id (FK → users)
part_id (FK → parts)
price (NUMERIC 12,2)
stock_quantity (INTEGER)
min_order_quantity (INTEGER DEFAULT 1)
warehouse_location (VARCHAR 100)
updated_at (TIMESTAMP DEFAULT now())
UNIQUE (user_id, part_id, warehouse_location)
```
**`inventory_uploads`** (new):
```
id_upload (PK)
user_id (FK → users)
filename (VARCHAR 200)
status (VARCHAR 20: pending, processing, completed, failed)
rows_total (INTEGER)
rows_imported (INTEGER)
rows_errors (INTEGER)
error_log (TEXT)
created_at (TIMESTAMP DEFAULT now())
completed_at (TIMESTAMP)
```
**`inventory_column_mappings`** (new):
```
id_mapping (PK)
user_id (FK → users, UNIQUE)
mapping (JSONB)
```
Example JSONB mapping:
```json
{
"part_number": "COLUMNA_A",
"price": "PRECIO_VENTA",
"stock": "EXISTENCIAS",
"location": "SUCURSAL"
}
```
### Upload Flow
1. Bodega uploads CSV/Excel → `POST /api/inventory/upload`
2. Backend reads file, applies JSONB mapping for that bodega
3. Matches part numbers against `parts.oem_part_number`
4. UPSERT into `warehouse_inventory`
5. Records result in `inventory_uploads`
### Catalog Display (authenticated TALLER)
```
Disponibilidad en bodegas:
BODEGA CENTRAL MX | $450.00 | 12 en stock | Guadalajara
REFACCIONES DEL NORTE | $485.00 | 3 en stock | Monterrey
```
- Only authenticated talleres see prices and stock
- Public users see catalog without prices
---
## Section 3: Aftermarket Parts Migration
### Current Problem
- 357,360 parts with `AFT-` prefix in `parts` table treated as OEM
- Format: `AFT-{articleNo}-{supplierName}` (e.g., `AFT-AC191-PARTQUIP`)
- Description: `"Aftermarket PARTQUIP"`
- Have vehicle_parts linked
- `aftermarket_parts` table exists but has only 1 record
### Migration Steps
**Step 1: Parse AFT- prefix**
```
AFT-AC191-PARTQUIP → part_number: AC191, manufacturer: PARTQUIP
AFT-10-0058-Airstal → part_number: 10-0058, manufacturer: Airstal
```
Logic: last segment after last `-` that matches a `manufacturers.name_manufacture` is the manufacturer. The rest (without `AFT-`) is the part number.
**Step 2: Find corresponding OEM part**
- Search `part_cross_references` where `cross_reference_number` = articleNo and `source_ref` = supplierName
- That gives us the `part_id` of the real OEM part
- If no cross-reference, search via `vehicle_parts` — OEM parts linked to same vehicles in same category
**Step 3: Populate `aftermarket_parts`**
```
oem_part_id → the OEM part found
manufacturer_id → FK to manufacturer (PARTQUIP, Airstal, etc.)
part_number → AC191 (clean, no prefix)
name_aftermarket_parts → original name_part
```
**Step 4: Migrate vehicle_parts**
- vehicle_parts pointing to AFT- part get re-linked to the real OEM part
- Or deleted if OEM already has that link (ON CONFLICT DO NOTHING)
**Step 5: Delete AFT- parts from `parts`**
- Once migrated to `aftermarket_parts` and re-linked, remove from `parts`
### Import Pipeline Changes
- `import_live.py` and `import_tecdoc_parts.py` stop creating `AFT-` parts in `parts`
- Instead insert directly into `aftermarket_parts` with clean manufacturer and part number
- `vehicle_parts` only link to real OEM parts
### Catalog Display
```
Alternativas aftermarket:
PARTQUIP AC191 | Ver disponibilidad →
BOSCH 0 986 AB2 854 | Ver disponibilidad →
KAWE 6497 10 | Ver disponibilidad →
```
---
## Section 4: API Endpoints & Pages
### New Endpoints
**Auth:**
| Method | Route | Access | Description |
|--------|-------|--------|-------------|
| POST | `/api/auth/register` | Public | Register taller/bodega |
| POST | `/api/auth/login` | Public | Login → JWT + refresh |
| POST | `/api/auth/refresh` | Authenticated | Renew access token |
| GET | `/api/auth/me` | Authenticated | User profile |
**Inventory (BODEGA):**
| Method | Route | Access | Description |
|--------|-------|--------|-------------|
| POST | `/api/inventory/upload` | BODEGA | Upload CSV/Excel |
| GET | `/api/inventory/uploads` | BODEGA | Upload history |
| GET | `/api/inventory/mapping` | BODEGA | View column mapping |
| PUT | `/api/inventory/mapping` | BODEGA | Configure mapping |
| GET | `/api/inventory/items` | BODEGA | View own inventory |
| DELETE | `/api/inventory/items` | BODEGA | Clear inventory |
**Availability (TALLER):**
| Method | Route | Access | Description |
|--------|-------|--------|-------------|
| GET | `/api/parts/{id}/availability` | TALLER | Prices/stock from all bodegas |
| GET | `/api/parts/{id}/aftermarket` | Public | Aftermarket alternatives list |
**Admin:**
| Method | Route | Access | Description |
|--------|-------|--------|-------------|
| GET | `/api/admin/users` | ADMIN | List users |
| PUT | `/api/admin/users/{id}/activate` | ADMIN | Approve/deactivate account |
### New Pages
**`login.html`** — Login/registration form. Redirects by role after login.
**`bodega.html`** — Warehouse panel:
- Configure column mapping
- Upload CSV/Excel
- View upload history with status
- View current inventory with search
### Modified Pages
- **`index.html` / `demo.html`** — Add "Disponibilidad en bodegas" section in part detail (TALLER only). Add "Alternativas aftermarket" section (public).
- **`admin.html`** — Add "Usuarios" tab for account management.
- **`nav.js`** — Add login/logout button, show username.
### Auth Middleware
```
Public: catalog, search, landing, login, aftermarket list
TALLER: prices, availability, history
BODEGA: upload, mapping, own inventory
ADMIN/OWNER: all above + user management + admin panel
```

File diff suppressed because it is too large Load Diff

View File

@@ -5,3 +5,6 @@ lxml>=4.9.0
sqlalchemy>=2.0 sqlalchemy>=2.0
psycopg2-binary>=2.9 psycopg2-binary>=2.9
flask-sqlalchemy>=3.1 flask-sqlalchemy>=3.1
PyJWT>=2.8
bcrypt>=4.0
openpyxl>=3.1

144
scripts/import_live.py Normal file
View File

@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""
Live importer: watches detail files and imports OEM data as it arrives.
Runs in a loop, importing new detail files every 30 seconds.
"""
import json
import time
import psycopg2
from pathlib import Path
DB_URL = "postgresql://nexus:nexus_autoparts_2026@localhost/nexus_autoparts"
DETAILS_DIR = Path("/home/Autopartes/data/tecdoc/parts/details")
ARTICLES_DIR = Path("/home/Autopartes/data/tecdoc/parts/articles")
TRACK_FILE = Path("/home/Autopartes/data/tecdoc/parts/.imported_ids")
INTERVAL = 30 # seconds between import runs
def load_imported():
"""Load set of already-imported articleIds."""
if TRACK_FILE.exists():
return set(TRACK_FILE.read_text().split())
return set()
def save_imported(ids):
TRACK_FILE.write_text("\n".join(ids))
def run():
imported = load_imported()
print(f"Already imported: {len(imported)} articles", flush=True)
# Build article→category mapping once
article_cats = {}
for f in ARTICLES_DIR.glob("*.json"):
parts = f.stem.split("_")
if len(parts) != 2:
continue
cat_id = int(parts[1])
try:
for a in json.loads(f.read_text()):
aid = a.get('articleId')
if aid and aid not in article_cats:
article_cats[aid] = cat_id
except:
continue
print(f"Article→category mappings: {len(article_cats):,}", flush=True)
while True:
detail_files = list(DETAILS_DIR.glob("*.json"))
new_files = [f for f in detail_files if f.stem not in imported]
if not new_files:
print(f" [{time.strftime('%H:%M:%S')}] No new files. Total imported: {len(imported):,}. Waiting...", flush=True)
time.sleep(INTERVAL)
continue
print(f" [{time.strftime('%H:%M:%S')}] Found {len(new_files)} new detail files to import", flush=True)
conn = psycopg2.connect(DB_URL)
cur = conn.cursor()
# Load caches
cur.execute("SELECT oem_part_number, id_part FROM parts WHERE oem_part_number IS NOT NULL")
part_cache = {r[0]: r[1] for r in cur.fetchall()}
cur.execute("SELECT id_manufacture, name_manufacture FROM manufacturers")
mfr_cache = {r[1]: r[0] for r in cur.fetchall()}
stats = {'parts': 0, 'xrefs': 0, 'mfrs': 0, 'updated': 0}
for f in new_files:
article_id = f.stem
try:
data = json.loads(f.read_text())
except:
imported.add(article_id)
continue
oem_list = data.get('articleOemNo', [])
article = data.get('article', {}) or {}
article_no = article.get('articleNo', '')
supplier = article.get('supplierName', '')
product_name = article.get('articleProductName', '')
if not oem_list:
imported.add(article_id)
continue
# Ensure manufacturer
if supplier and supplier not in mfr_cache:
cur.execute(
"INSERT INTO manufacturers (name_manufacture) VALUES (%s) RETURNING id_manufacture",
(supplier,))
mfr_cache[supplier] = cur.fetchone()[0]
stats['mfrs'] += 1
for oem in oem_list:
oem_no = oem.get('oemDisplayNo', '')
oem_brand = oem.get('oemBrand', '')
if not oem_no:
continue
if oem_no not in part_cache:
cur.execute("""
INSERT INTO parts (oem_part_number, name_part, description)
VALUES (%s, %s, %s)
ON CONFLICT (oem_part_number) DO UPDATE SET name_part = EXCLUDED.name_part
RETURNING id_part
""", (oem_no, product_name, f"OEM {oem_brand}"))
part_cache[oem_no] = cur.fetchone()[0]
stats['parts'] += 1
else:
# Update the existing AFT- placeholder with real OEM number
stats['updated'] += 1
part_id = part_cache[oem_no]
# Cross-reference
if article_no and supplier:
cur.execute("""
INSERT INTO part_cross_references (part_id, cross_reference_number, source_ref)
VALUES (%s, %s, %s) ON CONFLICT DO NOTHING
""", (part_id, article_no, supplier))
stats['xrefs'] += 1
imported.add(article_id)
conn.commit()
cur.close()
conn.close()
save_imported(imported)
total_details = len(list(DETAILS_DIR.glob("*.json")))
print(f" Imported batch: +{stats['parts']} parts, +{stats['xrefs']} xrefs, +{stats['mfrs']} mfrs | "
f"Total imported: {len(imported):,} | Details on disk: {total_details:,}", flush=True)
time.sleep(INTERVAL)
if __name__ == "__main__":
run()

148
scripts/import_phase1.py Normal file
View File

@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""
Quick import of Phase 1 TecDoc article data into PostgreSQL.
Imports aftermarket parts and their vehicle mappings from article list files,
without waiting for OEM detail downloads.
"""
import json
import psycopg2
from pathlib import Path
DB_URL = "postgresql://nexus:nexus_autoparts_2026@localhost/nexus_autoparts"
ARTICLES_DIR = Path("/home/Autopartes/data/tecdoc/parts/articles")
DETAILS_DIR = Path("/home/Autopartes/data/tecdoc/parts/details")
def run():
conn = psycopg2.connect(DB_URL)
cur = conn.cursor()
# Load category mapping: tecdoc_id → id_part_category
cur.execute("SELECT id_part_category, tecdoc_id FROM part_categories WHERE tecdoc_id IS NOT NULL")
cat_map = {r[1]: r[0] for r in cur.fetchall()}
# Load existing manufacturers
cur.execute("SELECT id_manufacture, name_manufacture FROM manufacturers")
mfr_cache = {r[1]: r[0] for r in cur.fetchall()}
# Load existing parts by OEM
cur.execute("SELECT oem_part_number, id_part FROM parts WHERE oem_part_number IS NOT NULL")
part_cache = {r[0]: r[1] for r in cur.fetchall()}
# Load existing cross-refs to avoid duplicates
cur.execute("SELECT part_id, cross_reference_number, source_ref FROM part_cross_references")
xref_set = {(r[0], r[1], r[2]) for r in cur.fetchall()}
# Also check detail files for OEM numbers
detail_oem = {} # articleId → list of {oemBrand, oemDisplayNo}
detail_files = list(DETAILS_DIR.glob("*.json"))
print(f"Loading {len(detail_files)} detail files for OEM data...", flush=True)
for f in detail_files:
try:
data = json.loads(f.read_text())
article = data.get('article', {})
if article and article.get('oemNo'):
detail_oem[int(f.stem)] = article['oemNo']
except:
continue
stats = {'parts': 0, 'xrefs': 0, 'mfrs': 0, 'skipped': 0}
article_files = sorted(ARTICLES_DIR.glob("*.json"))
print(f"Processing {len(article_files)} article files...", flush=True)
# Collect all unique articles across all files
all_articles = {} # articleId → article data + category
for f in article_files:
parts = f.stem.split("_")
if len(parts) != 2:
continue
cat_id = int(parts[1])
cat_db_id = cat_map.get(cat_id)
try:
articles = json.loads(f.read_text())
except:
continue
for a in articles:
aid = a.get('articleId')
if aid and aid not in all_articles:
a['_cat_db_id'] = cat_db_id
a['_cat_td_id'] = cat_id
all_articles[aid] = a
print(f"Unique articles to process: {len(all_articles):,}", flush=True)
batch = 0
for aid, a in all_articles.items():
article_no = a.get('articleNo', '')
supplier = a.get('supplierName', '')
product_name = a.get('articleProductName', '')
cat_db_id = a.get('_cat_db_id')
if not article_no or not supplier:
stats['skipped'] += 1
continue
# Ensure manufacturer exists
if supplier not in mfr_cache:
cur.execute(
"INSERT INTO manufacturers (name_manufacture) VALUES (%s) RETURNING id_manufacture",
(supplier,))
mfr_cache[supplier] = cur.fetchone()[0]
stats['mfrs'] += 1
# If we have OEM details for this article, create OEM parts
oem_numbers = detail_oem.get(aid, [])
if oem_numbers:
for oem in oem_numbers:
oem_no = oem.get('oemDisplayNo', '')
oem_brand = oem.get('oemBrand', '')
if not oem_no:
continue
if oem_no not in part_cache:
cur.execute("""
INSERT INTO parts (oem_part_number, name_part, description)
VALUES (%s, %s, %s)
ON CONFLICT (oem_part_number) DO UPDATE SET name_part = EXCLUDED.name_part
RETURNING id_part
""", (oem_no, product_name, f"OEM {oem_brand}"))
part_cache[oem_no] = cur.fetchone()[0]
stats['parts'] += 1
part_id = part_cache[oem_no]
# Add cross-reference (aftermarket → OEM)
xref_key = (part_id, article_no, supplier)
if xref_key not in xref_set:
cur.execute("""
INSERT INTO part_cross_references (part_id, cross_reference_number, source_ref)
VALUES (%s, %s, %s) ON CONFLICT DO NOTHING
""", (part_id, article_no, supplier))
xref_set.add(xref_key)
stats['xrefs'] += 1
else:
# No OEM data yet - skip, will be imported when detail arrives
pass
batch += 1
if batch % 5000 == 0:
conn.commit()
print(f" {batch:,}/{len(all_articles):,}{stats['parts']:,} parts, {stats['xrefs']:,} xrefs", flush=True)
conn.commit()
cur.close()
conn.close()
print(f"\n{'='*50}", flush=True)
print(f"IMPORT COMPLETE", flush=True)
print(f" Parts: {stats['parts']:,}", flush=True)
print(f" Cross-refs: {stats['xrefs']:,}", flush=True)
print(f" Manufacturers: {stats['mfrs']:,}", flush=True)
print(f" Skipped: {stats['skipped']:,}", flush=True)
print(f"{'='*50}", flush=True)
if __name__ == "__main__":
run()

414
scripts/import_tecdoc.py Normal file
View File

@@ -0,0 +1,414 @@
#!/usr/bin/env python3
"""
Import vehicle data from TecDoc (Apify) into Nexus Autoparts PostgreSQL.
Two-phase approach:
Phase 1: Download all data from TecDoc API to local JSON files
Phase 2: Import JSON files into PostgreSQL
Usage:
python3 scripts/import_tecdoc.py download # Phase 1: fetch from API
python3 scripts/import_tecdoc.py download --brand TOYOTA # Single brand
python3 scripts/import_tecdoc.py import # Phase 2: load into DB
python3 scripts/import_tecdoc.py status # Check progress
"""
import os
import sys
import json
import time
import argparse
import requests
import psycopg2
from datetime import datetime
from pathlib import Path
# --- Config ---
APIFY_TOKEN = os.environ.get("APIFY_TOKEN", "apify_api_l5SrcwYyanAO45AFxrEpviUcuVRIFK2yPdc5")
APIFY_ACTOR = "making-data-meaningful~tecdoc"
APIFY_URL = f"https://api.apify.com/v2/acts/{APIFY_ACTOR}/run-sync-get-dataset-items"
DB_URL = os.environ.get("DATABASE_URL", "postgresql://nexus:nexus_autoparts_2026@localhost/nexus_autoparts")
TYPE_ID = 1 # Passenger cars
LANG_ID = 4 # English
COUNTRY_ID = 153 # Mexico
DATA_DIR = Path("/home/Autopartes/data/tecdoc")
APIFY_DELAY = 1.0 # seconds between API calls
def apify_call(input_data, retries=3):
"""Call Apify actor and return result."""
for attempt in range(retries):
try:
resp = requests.post(
APIFY_URL, params={"token": APIFY_TOKEN},
headers={"Content-Type": "application/json"},
json=input_data, timeout=120
)
if resp.status_code in (200, 201):
data = resp.json()
return data[0] if isinstance(data, list) and data else data
elif resp.status_code == 429:
wait = 15 * (attempt + 1)
print(f" Rate limited, waiting {wait}s...", flush=True)
time.sleep(wait)
else:
print(f" HTTP {resp.status_code}: {resp.text[:100]}", flush=True)
time.sleep(5)
except Exception as e:
print(f" Error: {e}", flush=True)
time.sleep(5)
return None
# ──────────────── Phase 1: Download ────────────────
def download(brand_filter=None):
"""Download all TecDoc data to local JSON files."""
DATA_DIR.mkdir(parents=True, exist_ok=True)
# Step 1: Manufacturers
mfr_file = DATA_DIR / "manufacturers.json"
if mfr_file.exists():
manufacturers = json.loads(mfr_file.read_text())
print(f"Loaded {len(manufacturers)} cached manufacturers", flush=True)
else:
print("Fetching manufacturers...", flush=True)
result = apify_call({"endpoint_manufacturerIdsByTypeId": True, "manufacturer_typeId_2": TYPE_ID})
manufacturers = result["manufacturers"]
mfr_file.write_text(json.dumps(manufacturers, indent=1))
print(f" Saved {len(manufacturers)} manufacturers", flush=True)
if brand_filter:
manufacturers = [m for m in manufacturers if brand_filter.upper() in m["manufacturerName"].upper()]
print(f"Filtered to {len(manufacturers)} matching '{brand_filter}'", flush=True)
# Step 2: Models for each manufacturer
models_dir = DATA_DIR / "models"
models_dir.mkdir(exist_ok=True)
for i, mfr in enumerate(manufacturers):
mfr_id = mfr["manufacturerId"]
mfr_name = mfr["manufacturerName"]
model_file = models_dir / f"{mfr_id}.json"
if model_file.exists():
continue # Skip already downloaded
print(f"[{i+1}/{len(manufacturers)}] {mfr_name} (id={mfr_id})", flush=True)
time.sleep(APIFY_DELAY)
result = apify_call({
"endpoint_modelsByTypeManufacturer": True,
"models_typeId_1": TYPE_ID,
"models_manufacturerId_1": mfr_id,
"models_langId_1": LANG_ID,
"models_countryFilterId_1": COUNTRY_ID
})
models = result.get("models", []) if result else []
model_file.write_text(json.dumps(models, indent=1))
print(f" {len(models)} models", flush=True)
# Step 3: Vehicle types for each model
vehicles_dir = DATA_DIR / "vehicles"
vehicles_dir.mkdir(exist_ok=True)
# Iterate all model files
total_models = 0
processed = 0
for model_file in sorted(models_dir.glob("*.json")):
mfr_id = model_file.stem
models = json.loads(model_file.read_text())
total_models += len(models)
for model in models:
td_model_id = model["modelId"]
vehicle_file = vehicles_dir / f"{td_model_id}.json"
if vehicle_file.exists():
processed += 1
continue
print(f" [{processed+1}/{total_models}] Model {model['modelName']} (id={td_model_id})", flush=True)
time.sleep(APIFY_DELAY)
result = apify_call({
"endpoint_vehicleEngineTypesByModel": True,
"vehicle_typeId_3": TYPE_ID,
"vehicle_modelId_3": td_model_id,
"vehicle_langId_3": LANG_ID,
"vehicle_countryFilterId_3": COUNTRY_ID
})
vehicles = result.get("modelTypes", []) if result else []
vehicle_file.write_text(json.dumps(vehicles, indent=1))
processed += 1
print(f"\nDownload complete! {processed} model vehicle files.", flush=True)
# ──────────────── Phase 2: Import ────────────────
def parse_fuel_id(fuel_str):
if not fuel_str:
return None
f = fuel_str.lower()
if "diesel" in f:
return 1
if "electric" in f and "petrol" not in f and "gas" not in f:
return 2
return 3
def parse_body_id(model_name):
if not model_name:
return None
mapping = {
"Saloon": 1, "Sedan": 1, "Coupe": 2, "Coupé": 2,
"Hatchback": 3, "SUV": 4, "Off-Road": 4, "Crossover": 5,
"Truck": 6, "Van": 7, "Box Body": 7, "MPV": 8,
"Estate": 9, "Wagon": 9, "Kombi": 9,
"Convertible": 10, "Cabrio": 10, "Cabriolet": 10,
"Pick-up": 11, "Pickup": 11,
"Platform": 12, "Chassis": 12, "Bus": 13, "Roadster": 15,
}
for key, val in mapping.items():
if key in model_name:
return val
return None
def do_import():
"""Import downloaded JSON data into PostgreSQL."""
if not DATA_DIR.exists():
print("No data directory found. Run 'download' first.")
return
mfr_file = DATA_DIR / "manufacturers.json"
if not mfr_file.exists():
print("No manufacturers.json found. Run 'download' first.")
return
manufacturers = json.loads(mfr_file.read_text())
models_dir = DATA_DIR / "models"
vehicles_dir = DATA_DIR / "vehicles"
conn = psycopg2.connect(DB_URL)
cur = conn.cursor()
# Ensure years exist (19502027)
cur.execute("SELECT id_year, year_car FROM years")
year_cache = {r[1]: r[0] for r in cur.fetchall()}
for y in range(1950, 2028):
if y not in year_cache:
cur.execute("INSERT INTO years (year_car) VALUES (%s) RETURNING id_year", (y,))
year_cache[y] = cur.fetchone()[0]
conn.commit()
# Caches
brand_cache = {}
model_cache = {}
engine_cache = {}
mye_set = set()
stats = {"brands": 0, "models": 0, "engines": 0, "mye": 0, "skipped": 0}
current_year = datetime.now().year
for mfr in manufacturers:
mfr_id = mfr["manufacturerId"]
brand_name = mfr["manufacturerName"]
# Skip regional duplicates
if "(" in brand_name:
stats["skipped"] += 1
continue
model_file = models_dir / f"{mfr_id}.json"
if not model_file.exists():
continue
models = json.loads(model_file.read_text())
if not models:
continue
# Insert brand
if brand_name not in brand_cache:
cur.execute(
"INSERT INTO brands (name_brand) VALUES (%s) ON CONFLICT (name_brand) DO UPDATE SET name_brand=EXCLUDED.name_brand RETURNING id_brand",
(brand_name,))
brand_cache[brand_name] = cur.fetchone()[0]
stats["brands"] += 1
brand_id = brand_cache[brand_name]
for model in models:
model_name = model.get("modelName")
if not model_name:
continue
td_model_id = model["modelId"]
year_from = model.get("modelYearFrom", "")[:4] if model.get("modelYearFrom") else None
year_to = model.get("modelYearTo", "")[:4] if model.get("modelYearTo") else None
body_id = parse_body_id(model_name)
# Insert model
model_key = (brand_id, model_name)
if model_key not in model_cache:
cur.execute(
"""INSERT INTO models (brand_id, name_model, id_body, production_start_year, production_end_year)
VALUES (%s, %s, %s, %s, %s) RETURNING id_model""",
(brand_id, model_name, body_id,
int(year_from) if year_from else None,
int(year_to) if year_to else None))
model_cache[model_key] = cur.fetchone()[0]
stats["models"] += 1
model_db_id = model_cache[model_key]
# Load vehicles
vehicle_file = vehicles_dir / f"{td_model_id}.json"
if not vehicle_file.exists():
continue
vehicles = json.loads(vehicle_file.read_text())
if not vehicles:
continue
# Dedup by vehicleId
seen_v = {}
for v in vehicles:
vid = v["vehicleId"]
if vid not in seen_v:
seen_v[vid] = v
seen_v[vid]["_codes"] = [v.get("engineCodes", "")]
else:
c = v.get("engineCodes", "")
if c and c not in seen_v[vid]["_codes"]:
seen_v[vid]["_codes"].append(c)
for v in seen_v.values():
cap_lt = float(v["capacityLt"]) if v.get("capacityLt") else 0
cylinders = v.get("numberOfCylinders")
fuel = v.get("fuelType", "")
power_ps = float(v["powerPs"]) if v.get("powerPs") else 0
power_hp = int(power_ps * 0.9863) if power_ps else None
displacement = float(v["capacityTech"]) if v.get("capacityTech") else None
codes = ", ".join(v["_codes"])
fuel_id = parse_fuel_id(fuel)
# Build engine name
fl = fuel.lower() if fuel else ""
if "electric" in fl and "petrol" not in fl and cap_lt == 0:
eng_name = f"Electric {power_hp}hp" if power_hp else "Electric"
else:
eng_name = f"{cap_lt:.1f}L"
if cylinders:
eng_name += f" {cylinders}cyl"
if "diesel" in fl:
eng_name += " Diesel"
elif "electric" in fl:
eng_name += " Hybrid"
if power_hp:
eng_name += f" {power_hp}hp"
engine_key = (eng_name, displacement, cylinders, fuel_id, power_hp, codes)
if engine_key not in engine_cache:
cur.execute(
"""INSERT INTO engines (name_engine, displacement_cc, cylinders, id_fuel, power_hp, engine_code)
VALUES (%s, %s, %s, %s, %s, %s) RETURNING id_engine""",
(eng_name, displacement, cylinders, fuel_id, power_hp, codes))
engine_cache[engine_key] = cur.fetchone()[0]
stats["engines"] += 1
engine_db_id = engine_cache[engine_key]
start_str = v.get("constructionIntervalStart")
end_str = v.get("constructionIntervalEnd")
if not start_str:
continue
start_year = max(int(start_str[:4]), 1950)
end_year = min(int(end_str[:4]) if end_str else current_year, current_year + 1)
trim = v.get("typeEngineName", "")
for year in range(start_year, end_year + 1):
yid = year_cache.get(year)
if not yid:
continue
mye_key = (model_db_id, yid, engine_db_id, trim)
if mye_key in mye_set:
continue
mye_set.add(mye_key)
cur.execute(
"""INSERT INTO model_year_engine (model_id, year_id, engine_id, trim_level)
VALUES (%s, %s, %s, %s) ON CONFLICT DO NOTHING""",
(model_db_id, yid, engine_db_id, trim))
stats["mye"] += 1
# Commit per brand
conn.commit()
conn.commit()
cur.close()
conn.close()
print(f"\n{'='*60}", flush=True)
print(f"IMPORT COMPLETE", flush=True)
print(f" Brands: {stats['brands']} ({stats['skipped']} regional skipped)", flush=True)
print(f" Models: {stats['models']}", flush=True)
print(f" Engines: {stats['engines']}", flush=True)
print(f" MYE: {stats['mye']}", flush=True)
print(f"{'='*60}", flush=True)
# ──────────────── Status ────────────────
def status():
"""Show download progress."""
if not DATA_DIR.exists():
print("No data directory yet.")
return
mfr_file = DATA_DIR / "manufacturers.json"
if not mfr_file.exists():
print("Manufacturers not downloaded yet.")
return
manufacturers = json.loads(mfr_file.read_text())
models_dir = DATA_DIR / "models"
vehicles_dir = DATA_DIR / "vehicles"
model_files = list(models_dir.glob("*.json")) if models_dir.exists() else []
vehicle_files = list(vehicles_dir.glob("*.json")) if vehicles_dir.exists() else []
total_models = 0
for f in model_files:
total_models += len(json.loads(f.read_text()))
print(f"Manufacturers: {len(manufacturers)} total")
print(f"Model files: {len(model_files)} / {len(manufacturers)} brands downloaded")
print(f"Total models: {total_models}")
print(f"Vehicle files: {len(vehicle_files)} / {total_models} models downloaded")
if total_models > 0:
pct = len(vehicle_files) / total_models * 100
print(f"Progress: {pct:.1f}%")
remaining = total_models - len(vehicle_files)
est_minutes = remaining * APIFY_DELAY / 60 + remaining * 3 / 60 # delay + avg API time
print(f"Est. remaining: ~{est_minutes:.0f} minutes ({remaining} API calls)")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="TecDoc vehicle data import")
parser.add_argument("command", choices=["download", "import", "status"])
parser.add_argument("--brand", help="Filter by brand name")
args = parser.parse_args()
if args.command == "download":
download(brand_filter=args.brand)
elif args.command == "import":
do_import()
elif args.command == "status":
status()

View File

@@ -0,0 +1,531 @@
#!/usr/bin/env python3
"""
Import OEM parts data from TecDoc (Apify) into Nexus Autoparts PostgreSQL.
Three-phase approach:
Phase 1: Download categories per vehicle → JSON files
Phase 2: Download article lists per vehicle+category → JSON files
Phase 3: Download article details (OEM numbers) → JSON files
Phase 4: Import all JSON data into PostgreSQL
Uses one representative vehicleId per TecDoc model to minimize API calls.
Supports concurrent API calls for speed.
Usage:
python3 scripts/import_tecdoc_parts.py download # Phases 1-3
python3 scripts/import_tecdoc_parts.py import # Phase 4
python3 scripts/import_tecdoc_parts.py status # Check progress
"""
import os
import sys
import json
import time
import argparse
import requests
import psycopg2
from datetime import datetime
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, as_completed
# --- Config ---
APIFY_TOKEN = os.environ.get("APIFY_TOKEN", "apify_api_l5SrcwYyanAO45AFxrEpviUcuVRIFK2yPdc5")
APIFY_ACTOR = "making-data-meaningful~tecdoc"
APIFY_URL = f"https://api.apify.com/v2/acts/{APIFY_ACTOR}/run-sync-get-dataset-items"
DB_URL = os.environ.get("DATABASE_URL", "postgresql://nexus:nexus_autoparts_2026@localhost/nexus_autoparts")
TYPE_ID = 1 # Passenger cars
LANG_ID = 4 # English
COUNTRY_ID = 153 # Mexico
DATA_DIR = Path("/home/Autopartes/data/tecdoc")
PARTS_DIR = DATA_DIR / "parts"
ARTICLES_DIR = PARTS_DIR / "articles" # vehicle articles by category
DETAILS_DIR = PARTS_DIR / "details" # article OEM details
MAX_WORKERS = 30 # Concurrent API calls
APIFY_DELAY = 0.1 # Seconds between API calls per thread
# Top brands for Mexico & USA
TOP_BRANDS = [
'TOYOTA', 'NISSAN', 'CHEVROLET', 'VOLKSWAGEN', 'VW', 'HONDA', 'FORD',
'HYUNDAI', 'KIA', 'MAZDA', 'BMW', 'MERCEDES-BENZ', 'AUDI',
'JEEP', 'DODGE', 'CHRYSLER', 'RAM', 'GMC', 'BUICK', 'CADILLAC',
'SUBARU', 'MITSUBISHI', 'SUZUKI', 'ACURA', 'LEXUS', 'INFINITI',
'LINCOLN', 'FIAT', 'PEUGEOT', 'RENAULT', 'SEAT'
]
# Top-level TecDoc category IDs (from our DB)
TOP_CATEGORIES = None # Loaded dynamically
def apify_call(input_data, retries=3):
"""Call Apify actor and return result."""
for attempt in range(retries):
try:
resp = requests.post(
APIFY_URL, params={"token": APIFY_TOKEN},
headers={"Content-Type": "application/json"},
json=input_data, timeout=180
)
if resp.status_code in (200, 201):
data = resp.json()
return data[0] if isinstance(data, list) and data else data
elif resp.status_code == 429:
wait = 30 * (attempt + 1)
print(f" Rate limited, waiting {wait}s...", flush=True)
time.sleep(wait)
else:
print(f" HTTP {resp.status_code}: {resp.text[:200]}", flush=True)
time.sleep(5)
except Exception as e:
print(f" Error: {e}", flush=True)
time.sleep(5)
return None
def load_top_categories():
"""Load top-level TecDoc category IDs from database."""
conn = psycopg2.connect(DB_URL)
cur = conn.cursor()
cur.execute("SELECT tecdoc_id, name_part_category FROM part_categories WHERE tecdoc_id IS NOT NULL ORDER BY display_order")
cats = [(r[0], r[1]) for r in cur.fetchall()]
cur.close()
conn.close()
return cats
def get_representative_vehicles():
"""Get one representative vehicleId per TecDoc model for top brands."""
mfrs = json.loads((DATA_DIR / "manufacturers.json").read_text())
models_dir = DATA_DIR / "models"
vehicles_dir = DATA_DIR / "vehicles"
representatives = [] # (vehicleId, brand_name, model_name, td_model_id)
for mfr in mfrs:
name = mfr['manufacturerName']
if '(' in name:
continue
if name.upper() not in [b.upper() for b in TOP_BRANDS]:
continue
mfr_id = mfr['manufacturerId']
model_file = models_dir / f"{mfr_id}.json"
if not model_file.exists():
continue
models = json.loads(model_file.read_text())
for model in models:
td_model_id = model['modelId']
model_name = model.get('modelName', '')
vehicle_file = vehicles_dir / f"{td_model_id}.json"
if not vehicle_file.exists():
continue
vehicles = json.loads(vehicle_file.read_text())
if not vehicles:
continue
# Pick the first vehicle with a valid vehicleId as representative
vid = vehicles[0].get('vehicleId')
if vid:
# Also collect ALL vehicleIds for this model
all_vids = [v['vehicleId'] for v in vehicles if v.get('vehicleId')]
representatives.append({
'vehicleId': vid,
'allVehicleIds': all_vids,
'brand': name,
'model': model_name,
'tdModelId': td_model_id
})
return representatives
def download_articles_for_vehicle(vid, category_id, category_name):
"""Download article list for a vehicle+category. Returns article count."""
outfile = ARTICLES_DIR / f"{vid}_{category_id}.json"
if outfile.exists():
return 0 # Already downloaded
time.sleep(APIFY_DELAY)
result = apify_call({
'endpoint_partsArticleListByVehicleIdCategoryId': True,
'parts_vehicleId_18': vid,
'parts_categoryId_18': category_id,
'parts_typeId_18': TYPE_ID,
'parts_langId_18': LANG_ID,
})
if result and isinstance(result, dict) and 'articles' in result:
articles = result.get('articles') or []
outfile.write_text(json.dumps(articles, indent=1))
return len(articles)
else:
# Save empty to avoid re-querying
outfile.write_text("[]")
return 0
def download_article_detail(article_id):
"""Download OEM details for a single article."""
outfile = DETAILS_DIR / f"{article_id}.json"
if outfile.exists():
return True
time.sleep(APIFY_DELAY)
result = apify_call({
'endpoint_partsArticleDetailsByArticleId': True,
'parts_articleId_13': article_id,
'parts_langId_13': LANG_ID,
})
if result and result.get('articleOemNo'):
outfile.write_text(json.dumps(result, indent=1))
return True
elif result and isinstance(result.get('article'), dict):
outfile.write_text(json.dumps(result, indent=1))
return True
return False
# ──────────────── Download ────────────────
def download(brand_filter=None):
"""Download all parts data from TecDoc."""
PARTS_DIR.mkdir(parents=True, exist_ok=True)
ARTICLES_DIR.mkdir(parents=True, exist_ok=True)
DETAILS_DIR.mkdir(parents=True, exist_ok=True)
categories = load_top_categories()
print(f"Loaded {len(categories)} top-level categories", flush=True)
representatives = get_representative_vehicles()
if brand_filter:
representatives = [r for r in representatives if brand_filter.upper() in r['brand'].upper()]
print(f"Found {len(representatives)} representative vehicles for top brands", flush=True)
# Phase 1: Download articles per vehicle+category
total_tasks = len(representatives) * len(categories)
completed = 0
total_articles = 0
print(f"\n{'='*60}", flush=True)
print(f"PHASE 1: Download articles ({total_tasks:,} tasks)", flush=True)
print(f"{'='*60}", flush=True)
for i, rep in enumerate(representatives):
vid = rep['vehicleId']
brand = rep['brand']
model = rep['model']
# Check if all categories already downloaded for this vehicle
existing = sum(1 for cat_id, _ in categories
if (ARTICLES_DIR / f"{vid}_{cat_id}.json").exists())
if existing == len(categories):
completed += len(categories)
continue
print(f"[{i+1}/{len(representatives)}] {brand} {model} (vid={vid})", flush=True)
def download_task(args):
vid, cat_id, cat_name = args
return download_articles_for_vehicle(vid, cat_id, cat_name)
tasks = [(vid, cat_id, cat_name) for cat_id, cat_name in categories
if not (ARTICLES_DIR / f"{vid}_{cat_id}.json").exists()]
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
futures = {executor.submit(download_task, t): t for t in tasks}
for future in as_completed(futures):
try:
count = future.result()
total_articles += count
completed += 1
except Exception as e:
print(f" Task error: {e}", flush=True)
completed += 1
completed += existing # Count pre-existing
print(f"\nPhase 1 complete: {total_articles:,} articles found", flush=True)
# Phase 2: Collect unique articleIds and download OEM details
print(f"\n{'='*60}", flush=True)
print(f"PHASE 2: Collect unique articles & download OEM details", flush=True)
print(f"{'='*60}", flush=True)
unique_articles = set()
for f in ARTICLES_DIR.glob("*.json"):
try:
articles = json.loads(f.read_text())
for a in articles:
if 'articleId' in a:
unique_articles.add(a['articleId'])
except:
continue
# Filter out already downloaded
to_download = [aid for aid in unique_articles
if not (DETAILS_DIR / f"{aid}.json").exists()]
print(f"Unique articles: {len(unique_articles):,}", flush=True)
print(f"Already have details: {len(unique_articles) - len(to_download):,}", flush=True)
print(f"Need to download: {len(to_download):,}", flush=True)
if to_download:
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
futures = {executor.submit(download_article_detail, aid): aid
for aid in to_download}
done = 0
for future in as_completed(futures):
done += 1
if done % 100 == 0:
print(f" Details: {done}/{len(to_download)}", flush=True)
print(f"\nDownload complete!", flush=True)
# ──────────────── Import ────────────────
def do_import():
"""Import downloaded parts data into PostgreSQL."""
if not ARTICLES_DIR.exists():
print("No articles directory. Run 'download' first.")
return
conn = psycopg2.connect(DB_URL)
cur = conn.cursor()
# Load category mapping: tecdoc_id → (id_part_category, name)
cur.execute("SELECT id_part_category, tecdoc_id, name_part_category FROM part_categories WHERE tecdoc_id IS NOT NULL")
cat_map = {r[1]: (r[0], r[2]) for r in cur.fetchall()}
# Load group mapping: tecdoc_id → id_part_group
cur.execute("SELECT id_part_group, tecdoc_id, category_id FROM part_groups WHERE tecdoc_id IS NOT NULL")
group_map = {r[1]: (r[0], r[2]) for r in cur.fetchall()}
# Load brand mapping from DB
cur.execute("SELECT id_brand, name_brand FROM brands")
brand_db = {r[1].upper(): r[0] for r in cur.fetchall()}
# Build vehicle mapping: vehicleId → list of MYE ids
representatives = get_representative_vehicles()
# Build vehicleId → model mapping from our DB
# We need to map TecDoc modelId → our model_id
cur.execute("""
SELECT m.id_model, b.name_brand, m.name_model, m.id_brand
FROM models m JOIN brands b ON m.id_brand = b.id_brand
""")
db_models = cur.fetchall()
stats = {
'parts_inserted': 0, 'parts_existing': 0,
'vehicle_parts': 0, 'aftermarket': 0,
'cross_refs': 0, 'manufacturers': 0
}
# Process article detail files
detail_files = list(DETAILS_DIR.glob("*.json"))
print(f"Processing {len(detail_files)} article details...", flush=True)
# Cache for parts by OEM number
oem_cache = {} # oem_no → id_part
# Cache for manufacturers
mfr_cache = {} # supplier_name → id_manufacture
cur.execute("SELECT id_manufacture, name_manufacture FROM manufacturers")
for r in cur.fetchall():
mfr_cache[r[1]] = r[0]
# Cache existing parts
cur.execute("SELECT oem_part_number, id_part FROM parts WHERE oem_part_number IS NOT NULL")
for r in cur.fetchall():
oem_cache[r[0]] = r[1]
# Build article→vehicles mapping from article files
article_vehicles = {} # articleId → set of vehicleIds
article_category = {} # articleId → categoryId (TecDoc)
for f in ARTICLES_DIR.glob("*.json"):
parts = f.stem.split("_")
if len(parts) != 2:
continue
vid, cat_id = int(parts[0]), int(parts[1])
try:
articles = json.loads(f.read_text())
except:
continue
for a in articles:
aid = a.get('articleId')
if aid:
if aid not in article_vehicles:
article_vehicles[aid] = set()
article_vehicles[aid].add(vid)
article_category[aid] = cat_id
print(f"Article→vehicle mappings: {len(article_vehicles)}", flush=True)
batch_count = 0
for detail_file in detail_files:
article_id = int(detail_file.stem)
try:
data = json.loads(detail_file.read_text())
except:
continue
article = data.get('article', {})
if not article:
continue
article_no = article.get('articleNo', '')
supplier_name = article.get('supplierName', '')
product_name = article.get('articleProductName', '')
supplier_id = article.get('supplierId')
# Get OEM numbers
oem_numbers = article.get('oemNo', [])
if not oem_numbers:
continue
# Get category for this article
td_cat_id = article_category.get(article_id)
cat_info = cat_map.get(td_cat_id)
cat_db_id = cat_info[0] if cat_info else None
# Ensure manufacturer exists
if supplier_name and supplier_name not in mfr_cache:
cur.execute(
"INSERT INTO manufacturers (name_manufacture) VALUES (%s) RETURNING id_manufacture",
(supplier_name,))
mfr_cache[supplier_name] = cur.fetchone()[0]
stats['manufacturers'] += 1
mfr_id = mfr_cache.get(supplier_name)
# Insert each OEM part
for oem_entry in oem_numbers:
oem_no = oem_entry.get('oemDisplayNo', '')
oem_brand = oem_entry.get('oemBrand', '')
if not oem_no:
continue
# Insert OEM part if not exists
if oem_no not in oem_cache:
cur.execute("""
INSERT INTO parts (oem_part_number, name_part, name_es, category_id, description)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (oem_part_number) DO UPDATE SET name_part = EXCLUDED.name_part
RETURNING id_part
""", (oem_no, product_name, None, cat_db_id, f"OEM {oem_brand}"))
oem_cache[oem_no] = cur.fetchone()[0]
stats['parts_inserted'] += 1
else:
stats['parts_existing'] += 1
part_id = oem_cache[oem_no]
# Insert aftermarket cross-reference
if article_no and supplier_name:
cur.execute("""
INSERT INTO part_cross_references (part_id, cross_ref_number, id_ref_type, source_ref)
VALUES (%s, %s, NULL, %s)
ON CONFLICT DO NOTHING
""", (part_id, article_no, supplier_name))
stats['cross_refs'] += 1
batch_count += 1
if batch_count % 500 == 0:
conn.commit()
print(f" Processed {batch_count}/{len(detail_files)} articles, "
f"{stats['parts_inserted']} parts inserted", flush=True)
conn.commit()
cur.close()
conn.close()
print(f"\n{'='*60}", flush=True)
print(f"IMPORT COMPLETE", flush=True)
print(f" Parts inserted: {stats['parts_inserted']:,}", flush=True)
print(f" Parts existing: {stats['parts_existing']:,}", flush=True)
print(f" Cross-references: {stats['cross_refs']:,}", flush=True)
print(f" Manufacturers: {stats['manufacturers']:,}", flush=True)
print(f"{'='*60}", flush=True)
# ──────────────── Status ────────────────
def status():
"""Show download progress."""
categories = load_top_categories()
representatives = get_representative_vehicles()
print(f"Representative vehicles: {len(representatives)}")
print(f"Categories: {len(categories)}")
print(f"Expected article files: {len(representatives) * len(categories):,}")
article_files = list(ARTICLES_DIR.glob("*.json")) if ARTICLES_DIR.exists() else []
detail_files = list(DETAILS_DIR.glob("*.json")) if DETAILS_DIR.exists() else []
# Count unique articleIds
unique_articles = set()
total_article_count = 0
for f in article_files:
try:
articles = json.loads(f.read_text())
for a in articles:
if 'articleId' in a:
unique_articles.add(a['articleId'])
total_article_count += len(articles)
except:
continue
expected = len(representatives) * len(categories)
pct_articles = len(article_files) / expected * 100 if expected > 0 else 0
print(f"\nArticle files: {len(article_files):,} / {expected:,} ({pct_articles:.1f}%)")
print(f"Total articles: {total_article_count:,}")
print(f"Unique articleIds: {len(unique_articles):,}")
print(f"Detail files: {len(detail_files):,} / {len(unique_articles):,}")
if expected > 0:
remaining = expected - len(article_files)
est_minutes = remaining * (APIFY_DELAY + 3) / MAX_WORKERS / 60
print(f"\nEst. remaining (articles): ~{est_minutes:.0f} min ({remaining:,} calls)")
remaining_details = len(unique_articles) - len(detail_files)
if remaining_details > 0:
est_detail_min = remaining_details * (APIFY_DELAY + 3) / MAX_WORKERS / 60
print(f"Est. remaining (details): ~{est_detail_min:.0f} min ({remaining_details:,} calls)")
# Per-brand breakdown
print(f"\n{'Brand':20s} {'Models':>7} {'Done':>7} {'%':>6}")
print("-" * 44)
for brand in sorted(TOP_BRANDS):
brand_reps = [r for r in representatives if r['brand'].upper() == brand]
brand_done = sum(1 for r in brand_reps
for cat_id, _ in categories
if (ARTICLES_DIR / f"{r['vehicleId']}_{cat_id}.json").exists())
brand_total = len(brand_reps) * len(categories)
pct = brand_done / brand_total * 100 if brand_total > 0 else 0
print(f" {brand:18s} {len(brand_reps):>7} {brand_done:>7} {pct:>5.1f}%")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="TecDoc parts import")
parser.add_argument("command", choices=["download", "import", "status"])
parser.add_argument("--brand", help="Filter by brand name")
args = parser.parse_args()
if args.command == "download":
download(brand_filter=args.brand)
elif args.command == "import":
do_import()
elif args.command == "status":
status()

View File

@@ -0,0 +1,251 @@
#!/usr/bin/env python3
"""
Link parts to vehicles using TecDoc article files.
Maps: article file (vehicleId_categoryId.json) → parts → vehicle_parts (MYE ids)
Optimized v3: year+engine filtering + batch inserts.
"""
import json
import re
import psycopg2
from psycopg2.extras import execute_values
from pathlib import Path
DB_URL = "postgresql://nexus:nexus_autoparts_2026@localhost/nexus_autoparts"
DATA_DIR = Path("/home/Autopartes/data/tecdoc")
ARTICLES_DIR = DATA_DIR / "parts" / "articles"
DETAILS_DIR = DATA_DIR / "parts" / "details"
BATCH_SIZE = 50000
def parse_capacity_liters(cap):
"""Convert TecDoc capacityLt (e.g. '1998.0000' cc) to liters float (1.998)."""
try:
cc = float(cap)
return round(cc / 1000, 1)
except:
return None
def extract_engine_liters(engine_name):
"""Extract liters from engine name like '2.0L 4cyl 127hp'."""
m = re.match(r'(\d+\.\d+)L', engine_name)
if m:
return round(float(m.group(1)), 1)
return None
def run():
conn = psycopg2.connect(DB_URL)
cur = conn.cursor()
# Step 1: Build vehicleId → vehicle info from TecDoc files
print("Building vehicleId → vehicle info mapping...", flush=True)
mfrs = json.loads((DATA_DIR / "manufacturers.json").read_text())
vid_info = {} # vehicleId → {brand, model, year_start, year_end, liters}
for mfr in mfrs:
brand = mfr['manufacturerName']
if '(' in brand:
continue
mfr_id = mfr['manufacturerId']
model_file = DATA_DIR / "models" / f"{mfr_id}.json"
if not model_file.exists():
continue
models = json.loads(model_file.read_text())
for model in models:
model_name = model.get('modelName', '')
if not model_name:
continue
vehicle_file = DATA_DIR / "vehicles" / f"{model['modelId']}.json"
if not vehicle_file.exists():
continue
vehicles = json.loads(vehicle_file.read_text())
if not vehicles:
continue
for v in vehicles:
vid = v.get('vehicleId')
if not vid:
continue
# Parse year range
year_start = None
year_end = None
try:
cs = v.get('constructionIntervalStart', '')
if cs:
year_start = int(cs[:4])
ce = v.get('constructionIntervalEnd', '')
if ce:
year_end = int(ce[:4])
except:
pass
# Parse engine capacity
liters = parse_capacity_liters(v.get('capacityLt') or v.get('capacityTax'))
vid_info[vid] = {
'brand': brand,
'model': model_name,
'year_start': year_start,
'year_end': year_end,
'liters': liters,
}
print(f" {len(vid_info):,} vehicleIds mapped", flush=True)
# Step 2: Build (brand, modelName) → list of (mye_id, year, liters) from our DB
print("Building brand/model → MYE details mapping...", flush=True)
cur.execute("""
SELECT b.name_brand, m.name_model, mye.id_mye, y.year_car, e.name_engine
FROM model_year_engine mye
JOIN models m ON mye.model_id = m.id_model
JOIN brands b ON m.brand_id = b.id_brand
JOIN years y ON mye.year_id = y.id_year
JOIN engines e ON mye.engine_id = e.id_engine
""")
brand_model_to_myes = {}
for brand, model, mye_id, year, engine_name in cur.fetchall():
key = (brand, model)
liters = extract_engine_liters(engine_name)
if key not in brand_model_to_myes:
brand_model_to_myes[key] = []
brand_model_to_myes[key].append((mye_id, year, liters))
print(f" {len(brand_model_to_myes):,} brand/model combos with {sum(len(v) for v in brand_model_to_myes.values()):,} MYEs", flush=True)
# Step 3: Build OEM number → part_id from DB
print("Loading parts cache...", flush=True)
cur.execute("SELECT oem_part_number, id_part FROM parts WHERE oem_part_number IS NOT NULL")
part_cache = {r[0]: r[1] for r in cur.fetchall()}
print(f" {len(part_cache):,} parts cached", flush=True)
# Step 4: Load detail files to get articleId → OEM numbers
print("Loading article detail OEM mappings...", flush=True)
article_to_oems = {}
for f in DETAILS_DIR.glob("*.json"):
try:
data = json.loads(f.read_text())
oem_list = data.get('articleOemNo', [])
if oem_list:
oem_nos = [o.get('oemDisplayNo') for o in oem_list if o.get('oemDisplayNo')]
if oem_nos:
article_to_oems[int(f.stem)] = oem_nos
except:
continue
print(f" {len(article_to_oems):,} articles with OEM data", flush=True)
# Step 5: Process article files and create vehicle_parts
print("\nCreating vehicle_parts links (filtered + batch mode)...", flush=True)
stats = {'links': 0, 'skipped_no_mye': 0, 'skipped_no_part': 0, 'files': 0, 'filtered_out': 0}
pending = []
def flush_batch():
if not pending:
return
execute_values(cur, """
INSERT INTO vehicle_parts (model_year_engine_id, part_id, quantity_required)
VALUES %s ON CONFLICT DO NOTHING
""", pending, page_size=10000)
conn.commit()
pending.clear()
article_files = sorted(ARTICLES_DIR.glob("*.json"))
for f in article_files:
parts_split = f.stem.split("_")
if len(parts_split) != 2:
continue
vid = int(parts_split[0])
info = vid_info.get(vid)
if not info:
stats['skipped_no_mye'] += 1
continue
bm = (info['brand'], info['model'])
all_myes = brand_model_to_myes.get(bm, [])
if not all_myes:
stats['skipped_no_mye'] += 1
continue
# Filter MYEs by year range and engine capacity
td_ys = info['year_start']
td_ye = info['year_end']
td_lit = info['liters']
filtered_myes = []
for mye_id, mye_year, mye_liters in all_myes:
# Year filter: MYE year must fall within TecDoc construction interval
if td_ys and td_ye:
if mye_year < td_ys or mye_year > td_ye:
stats['filtered_out'] += 1
continue
elif td_ys:
if mye_year < td_ys:
stats['filtered_out'] += 1
continue
# Engine capacity filter: must match within 0.2L tolerance
if td_lit and mye_liters:
if abs(td_lit - mye_liters) > 0.2:
stats['filtered_out'] += 1
continue
filtered_myes.append(mye_id)
if not filtered_myes:
# Fallback: if filtering removed everything, skip
stats['skipped_no_mye'] += 1
continue
try:
articles = json.loads(f.read_text())
except:
continue
for a in articles:
aid = a.get('articleId')
article_no = a.get('articleNo', '')
supplier = a.get('supplierName', '')
if not aid:
continue
part_ids = set()
oem_nos = article_to_oems.get(aid, [])
for oem_no in oem_nos:
pid = part_cache.get(oem_no)
if pid:
part_ids.add(pid)
if not part_ids:
stats['skipped_no_part'] += 1
continue
for mye_id in filtered_myes:
for part_id in part_ids:
pending.append((mye_id, part_id, 1))
stats['links'] += 1
if len(pending) >= BATCH_SIZE:
flush_batch()
stats['files'] += 1
if stats['files'] % 500 == 0:
flush_batch()
print(f" {stats['files']:,}/{len(article_files):,} files | "
f"{stats['links']:,} links | {stats['filtered_out']:,} filtered out", flush=True)
flush_batch()
cur.close()
conn.close()
print(f"\n{'='*50}", flush=True)
print(f"LINKING COMPLETE", flush=True)
print(f" Files processed: {stats['files']:,}", flush=True)
print(f" Links created: {stats['links']:,}", flush=True)
print(f" Filtered out: {stats['filtered_out']:,}", flush=True)
print(f" Skipped (no MYE): {stats['skipped_no_mye']:,}", flush=True)
print(f" Skipped (no part):{stats['skipped_no_part']:,}", flush=True)
print(f"{'='*50}", flush=True)
if __name__ == "__main__":
run()

220
scripts/migrate_aftermarket.py Executable file
View File

@@ -0,0 +1,220 @@
#!/usr/bin/env python3
"""
Migrate AFT- prefixed parts from `parts` table to `aftermarket_parts` table.
Parts with oem_part_number like 'AFT-{partNumber}-{manufacturerName}' are
aftermarket parts stored incorrectly in the parts table. This script parses
them, links to OEM parts via cross-references, inserts into aftermarket_parts,
and deletes the originals from parts (CASCADE cleans vehicle_parts & cross_refs).
Usage:
python3 scripts/migrate_aftermarket.py
"""
import sys
import time
import psycopg2
from psycopg2.extras import execute_values
DB_DSN = "postgresql://nexus:nexus_autoparts_2026@localhost/nexus_autoparts"
BATCH_SIZE = 5000
def load_manufacturers(cur):
"""Load all manufacturer names and ids into a dict {lowercase_name: id}."""
cur.execute("SELECT id_manufacture, name_manufacture FROM manufacturers")
mfrs = {}
for row in cur.fetchall():
mfrs[row[1].lower()] = row[0]
print(f"Loaded {len(mfrs)} known manufacturers", flush=True)
return mfrs
def parse_aft_part(oem_part_number, known_manufacturers):
"""
Parse 'AFT-{partNumber}-{manufacturerName}' into (part_number, manufacturer_name).
The manufacturer is the longest right-side suffix (after a '-') that matches
a known manufacturer. Fallback: last segment is the manufacturer.
Examples:
AFT-AC191-PARTQUIP -> ('AC191', 'PARTQUIP')
AFT-10-0058-Airstal -> ('10-0058', 'Airstal')
AFT-A-123-Some-Brand -> ('A-123', 'Some-Brand') if 'some-brand' is known
"""
without_prefix = oem_part_number[4:] # remove 'AFT-'
segments = without_prefix.split('-')
if len(segments) < 2:
# Can't split — treat entire thing as part number, no manufacturer
return without_prefix, None
# Try increasingly longer suffixes from the right to find a known manufacturer
# e.g. for segments [A, B, C, D], try "D", "C-D", "B-C-D"
# (don't try the full string — need at least one segment for part_number)
for i in range(len(segments) - 1, 0, -1):
candidate = '-'.join(segments[i:])
if candidate.lower() in known_manufacturers:
part_number = '-'.join(segments[:i])
return part_number, candidate
# Fallback: last segment is manufacturer
manufacturer_name = segments[-1]
part_number = '-'.join(segments[:-1])
return part_number, manufacturer_name
def main():
conn = psycopg2.connect(DB_DSN)
conn.autocommit = False
cur = conn.cursor()
# Step 1: Load known manufacturers
known_manufacturers = load_manufacturers(cur)
# Step 2: Count AFT- parts
cur.execute("SELECT COUNT(*) FROM parts WHERE oem_part_number LIKE 'AFT-%%'")
total = cur.fetchone()[0]
print(f"Found {total:,} AFT- parts to migrate", flush=True)
if total == 0:
print("Nothing to do.")
cur.close()
conn.close()
return
# Load all AFT- parts into memory (357K rows is manageable)
print("Loading all AFT- parts into memory...", flush=True)
cur.execute(
"SELECT id_part, oem_part_number, name_part, description, group_id "
"FROM parts WHERE oem_part_number LIKE 'AFT-%%' "
"ORDER BY id_part"
)
all_aft_parts = cur.fetchall()
print(f" Loaded {len(all_aft_parts):,} rows", flush=True)
migrated = 0
skipped_no_mfr = 0
skipped_no_oem = 0
inserted = 0
batch_num = 0
new_manufacturers = 0
# Process in batches
for i in range(0, len(all_aft_parts), BATCH_SIZE):
batch = all_aft_parts[i:i + BATCH_SIZE]
stats = process_batch(conn, cur, batch, known_manufacturers)
inserted += stats['inserted']
skipped_no_mfr += stats['skipped_no_mfr']
skipped_no_oem += stats['skipped_no_oem']
new_manufacturers += stats['new_manufacturers']
migrated += len(batch)
batch_num += 1
print(f" Batch {batch_num}: processed {migrated:,}/{total:,} "
f"(inserted={inserted:,}, no_oem={skipped_no_oem:,}, "
f"no_mfr={skipped_no_mfr:,})", flush=True)
cur.close()
conn.close()
print(f"\n=== Migration Complete ===")
print(f" Total AFT- parts processed: {migrated:,}")
print(f" Inserted into aftermarket_parts: {inserted:,}")
print(f" Skipped (no manufacturer parsed): {skipped_no_mfr:,}")
print(f" Skipped (no OEM part found): {skipped_no_oem:,}")
print(f" New manufacturers created: {new_manufacturers:,}")
def process_batch(conn, cur, batch, known_manufacturers):
"""Process a batch of AFT- parts. Returns stats dict."""
stats = {'inserted': 0, 'skipped_no_mfr': 0, 'skipped_no_oem': 0, 'new_manufacturers': 0}
parts_to_delete = []
aftermarket_rows = [] # (oem_part_id, manufacturer_id, part_number, name_aftermarket_parts)
for id_part, oem_part_number, name_part, description, group_id in batch:
part_number, manufacturer_name = parse_aft_part(oem_part_number, known_manufacturers)
if not manufacturer_name:
stats['skipped_no_mfr'] += 1
# Still delete the malformed AFT- part from parts table
parts_to_delete.append(id_part)
continue
# Ensure manufacturer exists
mfr_key = manufacturer_name.lower()
if mfr_key not in known_manufacturers:
cur.execute(
"INSERT INTO manufacturers (name_manufacture) VALUES (%s) "
"ON CONFLICT (name_manufacture) DO NOTHING "
"RETURNING id_manufacture",
(manufacturer_name,)
)
row = cur.fetchone()
if row:
known_manufacturers[mfr_key] = row[0]
stats['new_manufacturers'] += 1
else:
# Was inserted by a concurrent process; fetch it
cur.execute(
"SELECT id_manufacture FROM manufacturers WHERE name_manufacture = %s",
(manufacturer_name,)
)
known_manufacturers[mfr_key] = cur.fetchone()[0]
manufacturer_id = known_manufacturers[mfr_key]
# Find the OEM part via cross-references
cur.execute(
"SELECT part_id FROM part_cross_references "
"WHERE cross_reference_number = %s AND source_ref = %s "
"LIMIT 1",
(part_number, manufacturer_name)
)
xref_row = cur.fetchone()
if not xref_row:
stats['skipped_no_oem'] += 1
# Still delete — it doesn't belong in parts table
parts_to_delete.append(id_part)
continue
oem_part_id = xref_row[0]
aftermarket_rows.append((oem_part_id, manufacturer_id, part_number, name_part))
parts_to_delete.append(id_part)
# Batch insert into aftermarket_parts
if aftermarket_rows:
execute_values(
cur,
"INSERT INTO aftermarket_parts "
"(oem_part_id, manufacturer_id, part_number, name_aftermarket_parts) "
"VALUES %s ON CONFLICT DO NOTHING",
aftermarket_rows,
page_size=1000
)
stats['inserted'] = len(aftermarket_rows)
# Delete dependent rows first (FK without CASCADE)
if parts_to_delete:
cur.execute(
"DELETE FROM vehicle_parts WHERE part_id = ANY(%s)",
(parts_to_delete,)
)
cur.execute(
"DELETE FROM part_cross_references WHERE part_id = ANY(%s)",
(parts_to_delete,)
)
cur.execute(
"DELETE FROM parts WHERE id_part = ANY(%s)",
(parts_to_delete,)
)
conn.commit()
return stats
if __name__ == '__main__':
t0 = time.time()
main()
elapsed = time.time() - t0
print(f"Elapsed: {elapsed:.1f}s")

View File

@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""
Migration: SaaS schema changes
- Update roles (ADMIN, OWNER, TALLER, BODEGA)
- Extend users table with business_name, is_active, created_at, last_login
- Create sessions, warehouse_inventory, inventory_uploads, inventory_column_mappings tables
- Add indexes
"""
import psycopg2
import sys
DB_URL = "postgresql://nexus:nexus_autoparts_2026@localhost/nexus_autoparts"
STATEMENTS = [
# ── Roles: UPSERT to desired values ──
"""
INSERT INTO roles (id_rol, name_rol) OVERRIDING SYSTEM VALUE VALUES (1, 'ADMIN')
ON CONFLICT (id_rol) DO UPDATE SET name_rol = EXCLUDED.name_rol;
""",
"""
INSERT INTO roles (id_rol, name_rol) OVERRIDING SYSTEM VALUE VALUES (2, 'OWNER')
ON CONFLICT (id_rol) DO UPDATE SET name_rol = EXCLUDED.name_rol;
""",
"""
INSERT INTO roles (id_rol, name_rol) OVERRIDING SYSTEM VALUE VALUES (3, 'TALLER')
ON CONFLICT (id_rol) DO UPDATE SET name_rol = EXCLUDED.name_rol;
""",
"""
INSERT INTO roles (id_rol, name_rol) OVERRIDING SYSTEM VALUE VALUES (4, 'BODEGA')
ON CONFLICT (id_rol) DO UPDATE SET name_rol = EXCLUDED.name_rol;
""",
# ── Extend users table ──
"ALTER TABLE users ADD COLUMN IF NOT EXISTS business_name VARCHAR(200);",
"ALTER TABLE users ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT false;",
"ALTER TABLE users ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT now();",
"ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login TIMESTAMP;",
# ── Unique index on users(email) ──
"""
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email);
""",
# ── Sessions table ──
"""
CREATE TABLE IF NOT EXISTS sessions (
id_session SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id_user) ON DELETE CASCADE,
refresh_token VARCHAR(500) UNIQUE NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT now()
);
""",
# ── Warehouse inventory table ──
"""
CREATE TABLE IF NOT EXISTS warehouse_inventory (
id_inventory BIGSERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id_user),
part_id INTEGER NOT NULL REFERENCES parts(id_part),
price NUMERIC(12,2),
stock_quantity INTEGER DEFAULT 0,
min_order_quantity INTEGER DEFAULT 1,
warehouse_location VARCHAR(100) DEFAULT 'Principal',
updated_at TIMESTAMP DEFAULT now(),
UNIQUE(user_id, part_id, warehouse_location)
);
""",
# ── Inventory uploads table ──
"""
CREATE TABLE IF NOT EXISTS inventory_uploads (
id_upload SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id_user),
filename VARCHAR(200),
status VARCHAR(20) DEFAULT 'pending',
rows_total INTEGER,
rows_imported INTEGER,
rows_errors INTEGER,
error_log TEXT,
created_at TIMESTAMP DEFAULT now(),
completed_at TIMESTAMP
);
""",
# ── Inventory column mappings table ──
"""
CREATE TABLE IF NOT EXISTS inventory_column_mappings (
id_mapping SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id_user),
mapping JSONB DEFAULT '{}'::jsonb
);
""",
# ── Indexes ──
"CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(refresh_token);",
"CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);",
"CREATE INDEX IF NOT EXISTS idx_wi_part ON warehouse_inventory(part_id);",
"CREATE INDEX IF NOT EXISTS idx_wi_user ON warehouse_inventory(user_id);",
# ── Activate existing admin users ──
"UPDATE users SET is_active = true WHERE id_rol = 1;",
]
def main():
conn = psycopg2.connect(DB_URL)
conn.autocommit = True
cur = conn.cursor()
for i, sql in enumerate(STATEMENTS, 1):
label = sql.strip().split('\n')[0].strip()[:80]
try:
cur.execute(sql)
print(f" [{i:2d}/{len(STATEMENTS)}] OK {label}")
except Exception as e:
print(f" [{i:2d}/{len(STATEMENTS)}] ERR {label}\n {e}")
sys.exit(1)
cur.close()
conn.close()
print(f"\nMigration complete — {len(STATEMENTS)} statements executed.")
if __name__ == "__main__":
main()

43
scripts/run_all_brands.sh Executable file
View File

@@ -0,0 +1,43 @@
#!/bin/bash
# Sequential download + import for all target brands
LOG="/tmp/tecdoc_all_brands.log"
SCRIPTS="/home/Autopartes/scripts"
BRANDS=("RENAULT" "NISSAN")
for BRAND in "${BRANDS[@]}"; do
echo "" | tee -a "$LOG"
echo "$(date): ========== Starting $BRAND ==========" | tee -a "$LOG"
# Start download
BRAND_LOG="/tmp/tecdoc_parts_$(echo $BRAND | tr ' ' '_').log"
python3 "$SCRIPTS/import_tecdoc_parts.py" download --brand "$BRAND" >> "$BRAND_LOG" 2>&1 &
DL_PID=$!
echo "$(date): Download started (PID $DL_PID)" | tee -a "$LOG"
# Start live importer
python3 "$SCRIPTS/import_live.py" >> /tmp/tecdoc_import_live.log 2>&1 &
LI_PID=$!
echo "$(date): Live importer started (PID $LI_PID)" | tee -a "$LOG"
# Wait for download to finish
wait $DL_PID
echo "$(date): Download for $BRAND complete!" | tee -a "$LOG"
# Give live importer time to catch up, then stop it
sleep 60
kill $LI_PID 2>/dev/null
wait $LI_PID 2>/dev/null
echo "$(date): Live importer stopped" | tee -a "$LOG"
# Run vehicle linker
echo "$(date): Starting vehicle linker for $BRAND..." | tee -a "$LOG"
python3 "$SCRIPTS/link_vehicle_parts.py" >> /tmp/tecdoc_linker.log 2>&1
echo "$(date): Linker for $BRAND complete!" | tee -a "$LOG"
echo "$(date): ========== $BRAND DONE ==========" | tee -a "$LOG"
done
echo "" | tee -a "$LOG"
echo "$(date): ALL BRANDS COMPLETE!" | tee -a "$LOG"