Compare commits
16 Commits
2c6b6e0160
...
fb6ea31100
| Author | SHA1 | Date | |
|---|---|---|---|
| fb6ea31100 | |||
| d269bc1ffb | |||
| 5e6bf788db | |||
| fe6542c45c | |||
| b1adf536f6 | |||
| eff04a5e60 | |||
| 4b01c57c88 | |||
| e5d074687a | |||
| 6c6a9eecd6 | |||
| 340d2fcef8 | |||
| 565f11aca6 | |||
| 744df6b3b8 | |||
| 09d3304b21 | |||
| c5e5f6ef7e | |||
| 6ef39d212c | |||
| f89d591fa9 |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -51,3 +51,13 @@ Thumbs.db
|
||||
# Backup files
|
||||
*.bak
|
||||
*.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
430
README.md
@@ -1,333 +1,205 @@
|
||||
# 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
|
||||
- Dashboard web moderno y responsivo para consultar y explorar datos
|
||||
- Herramientas de web scraping para recopilar datos de RockAuto.com
|
||||
- Interfaces de línea de comandos (CLI) y programática
|
||||
- Scripts de utilidad para gestión y mantenimiento de datos
|
||||
| Componente | Tecnologia |
|
||||
|------------|-----------|
|
||||
| Backend | Python 3, Flask |
|
||||
| Base de datos | PostgreSQL |
|
||||
| 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 |
|
||||
|----------|----------|
|
||||
| Marcas | 12 |
|
||||
| Modelos | 10,923 |
|
||||
| Motores | 10,919 |
|
||||
| Combinaciones modelo-año-motor | 12,075 |
|
||||
- **1.4M+** partes OEM
|
||||
- **300K+** partes aftermarket
|
||||
- **13M+** cross-references (numeros alternos, supersesiones, intercambios)
|
||||
- **12B+** vehicle-part links (fitment)
|
||||
- **100+** marcas, miles de modelos, anos 1956-2026
|
||||
|
||||
## Tecnologías Utilizadas
|
||||
## Features
|
||||
|
||||
### Backend
|
||||
- **Python 3** - Lenguaje principal
|
||||
- **SQLite 3** - Base de datos
|
||||
- **Flask 2.3.3** - Framework web
|
||||
- **BeautifulSoup4** - Web scraping
|
||||
- **requests** - HTTP client
|
||||
- **lxml** - Parser XML/HTML
|
||||
- **Catalogo de autopartes** con navegacion jerarquica: Marca > Modelo > Ano > Motor > Categoria > Grupo > Parte
|
||||
- **TecDoc integration** (via Apify) para importar datos OEM y aftermarket de Europa/Mexico
|
||||
- **SaaS multi-tenant** con roles: `ADMIN`, `OWNER`, `TALLER`, `BODEGA`
|
||||
- **JWT authentication** con access tokens (15 min) y refresh tokens (30 dias)
|
||||
- **Gestion de inventario** para bodegas con mapeo flexible de columnas CSV/Excel
|
||||
- **Disponibilidad de partes** en multiples bodegas con precios comparativos
|
||||
- **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
|
||||
- **HTML5** - Estructura
|
||||
- **Bootstrap 5.3.0** - Framework CSS
|
||||
- **JavaScript (ES6+)** - Lógica cliente
|
||||
- **Font Awesome 6.0.0** - Iconos
|
||||
## Quick Start
|
||||
|
||||
## Estructura del Proyecto
|
||||
### Requisitos previos
|
||||
|
||||
```
|
||||
Autopartes/
|
||||
├── 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
|
||||
```
|
||||
- Python 3.8+
|
||||
- PostgreSQL con la base `nexus_autoparts`
|
||||
|
||||
## Consola Pick/VT220
|
||||
|
||||
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.
|
||||
### Instalacion
|
||||
|
||||
```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.
|
||||
|
||||
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
|
||||
### Ejecutar el servidor
|
||||
|
||||
```bash
|
||||
cd dashboard
|
||||
cd /home/Autopartes/dashboard
|
||||
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
|
||||
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
|
||||
cd vehicle_database/scripts
|
||||
python3 query_interface.py
|
||||
# Importar partes TecDoc (OEM + aftermarket)
|
||||
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
|
||||
cd vehicle_scraper
|
||||
python3 rockauto_scraper_v2.py
|
||||
```
|
||||
| Ruta | Archivo | Descripcion |
|
||||
|------|---------|-------------|
|
||||
| `/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
|
||||
cd vehicle_scraper
|
||||
python3 manual_input.py
|
||||
```
|
||||
Documentacion completa en [`docs/API.md`](docs/API.md).
|
||||
|
||||
## 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 |
|
||||
|----------|--------|-------------|
|
||||
| `/api/brands` | GET | Obtiene todas las marcas |
|
||||
| `/api/models?brand=X` | GET | Obtiene modelos por marca |
|
||||
| `/api/years` | GET | Obtiene años disponibles |
|
||||
| `/api/engines` | GET | Obtiene motores disponibles |
|
||||
| `/api/vehicles` | GET | Búsqueda con filtros |
|
||||
### Inventario (`/api/inventory/`)
|
||||
- `GET/PUT /api/inventory/mapping` - Mapeo de columnas CSV
|
||||
- `POST /api/inventory/upload` - Subir CSV/Excel de inventario
|
||||
- `GET /api/inventory/items` - Listar inventario propio
|
||||
- `DELETE /api/inventory/items` - Limpiar inventario
|
||||
|
||||
### 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
|
||||
# Obtener todas las marcas
|
||||
curl http://localhost:5000/api/brands
|
||||
### Admin (`/api/admin/`)
|
||||
- `GET /api/admin/users` - Listar usuarios (auth: ADMIN/OWNER)
|
||||
- `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
|
||||
curl "http://localhost:5000/api/vehicles?brand=Toyota&year=2020"
|
||||
```
|
||||
### VIN Decoder
|
||||
- `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
|
||||
| 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 |
|
||||
## Configuracion
|
||||
|
||||
#### models
|
||||
| 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 |
|
||||
Archivo principal: [`config.py`](config.py)
|
||||
|
||||
#### engines
|
||||
| Campo | Tipo | Descripción |
|
||||
|-------|------|-------------|
|
||||
| id | INTEGER | Clave primaria |
|
||||
| name | TEXT | Nombre del motor |
|
||||
| displacement_cc | INTEGER | Cilindrada en cc |
|
||||
| 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 |
|
||||
| Variable | Default | Descripcion |
|
||||
|----------|---------|-------------|
|
||||
| `DATABASE_URL` | `postgresql://nexus:...@localhost/nexus_autoparts` | PostgreSQL connection string |
|
||||
| `JWT_SECRET` | `nexus-saas-secret-change-in-prod-2026` | Secreto para firmar tokens JWT |
|
||||
| `JWT_ACCESS_EXPIRES` | `900` (15 min) | Duracion del access token en segundos |
|
||||
| `JWT_REFRESH_EXPIRES` | `2592000` (30 dias) | Duracion del refresh token en segundos |
|
||||
|
||||
#### years
|
||||
| Campo | Tipo | Descripción |
|
||||
|-------|------|-------------|
|
||||
| id | INTEGER | Clave primaria |
|
||||
| year | INTEGER | Año |
|
||||
## Arquitectura
|
||||
|
||||
#### model_year_engine
|
||||
| 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
|
||||
Documentacion detallada en [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).
|
||||
|
||||
```
|
||||
brands ──┐
|
||||
│
|
||||
├──< models ──┐
|
||||
│ │
|
||||
years ───┼─────────────┼──< model_year_engine
|
||||
│ │
|
||||
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 │
|
||||
└─────────────────┘ └────────┬─────────┘
|
||||
│
|
||||
+------------------+
|
||||
| TecDoc (Apify) |
|
||||
+--------+---------+
|
||||
|
|
||||
download/import
|
||||
|
|
||||
v
|
||||
┌─────────────────┐ ┌──────────────────┐
|
||||
│ Manual Input │────>│ SQLite Database │
|
||||
└─────────────────┘ └────────┬─────────┘
|
||||
│
|
||||
┌───────────────────────┼───────────────────────┐
|
||||
│ │ │
|
||||
v v v
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
||||
│ Flask API │ │ Pick Console │ │ CSV Importer │
|
||||
└────────┬────────┘ │ (VT220/Rich) │ └──────────────────┘
|
||||
│ └──────────────────┘
|
||||
v
|
||||
┌─────────────────┐
|
||||
│ Web Dashboard │
|
||||
│ (Browser) │
|
||||
└─────────────────┘
|
||||
+----------+ +--------+---------+ +----------------+
|
||||
| Frontend |<--->| Flask Server |<--->| PostgreSQL |
|
||||
| (HTML/JS)| | (server.py) | | nexus_autoparts|
|
||||
+----------+ +--------+---------+ +----------------+
|
||||
|
|
||||
JWT auth (PyJWT)
|
||||
|
|
||||
+------------+------------+
|
||||
| | |
|
||||
TALLER BODEGA ADMIN
|
||||
(consulta) (inventario) (gestion)
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
@@ -15,6 +15,11 @@ SQLITE_PATH = os.path.join(
|
||||
"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
|
||||
APP_NAME = "NEXUS AUTOPARTS"
|
||||
APP_SLOGAN = "Tu conexión directa con las partes que necesitas"
|
||||
|
||||
@@ -668,6 +668,15 @@
|
||||
<span>Exportar CSV</span>
|
||||
</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>
|
||||
|
||||
<!-- Main Content -->
|
||||
@@ -1207,6 +1216,35 @@
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -118,6 +118,9 @@ function showSection(sectionId) {
|
||||
case 'diagrams':
|
||||
// Just show section, user uses search
|
||||
break;
|
||||
case 'users':
|
||||
loadUsers();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1226,18 +1229,18 @@ function renderPagination(containerId, pagination, pageKey, loadFunction) {
|
||||
let html = '';
|
||||
|
||||
// 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
|
||||
const startPage = Math.max(1, page - 2);
|
||||
const endPage = Math.min(total_pages, page + 2);
|
||||
|
||||
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
|
||||
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;
|
||||
}
|
||||
@@ -1967,3 +1970,107 @@ async function deleteHotspot(hotspotId) {
|
||||
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
98
dashboard/auth.py
Normal 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
485
dashboard/bodega.css
Normal 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
164
dashboard/bodega.html
Normal 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">📦</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">×</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
522
dashboard/bodega.js
Normal 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
660
dashboard/captura.css
Normal 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
99
dashboard/captura.html
Normal 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
707
dashboard/captura.js
Normal 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">📋</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 + ' · ' + esc(v.engine) +
|
||||
(v.trim_level ? ' · ' + 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) + '">« 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 »</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">◀ Volver</button>' +
|
||||
'<button class="btn btn-primary" id="btn-complete-vehicle">Terminado ✓</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">▼</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">✕</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">✓</button>' +
|
||||
'<button class="pr-btn pr-delete" title="Quitar">✕</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">✅</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) + ' › ' + 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">📷</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) + ' · ' + 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
282
dashboard/cuentas.css
Normal 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
102
dashboard/cuentas.html
Normal 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">« 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
222
dashboard/cuentas.js
Normal 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) + '">«</button>' +
|
||||
'<span class="page-info">Pag ' + pag.page + '/' + pag.total_pages + '</span>' +
|
||||
'<button ' + (pag.page >= pag.total_pages ? 'disabled' : '') + ' data-p="' + (pag.page + 1) + '">»</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();
|
||||
})();
|
||||
@@ -41,37 +41,31 @@ class VehicleDashboard {
|
||||
|
||||
async loadStats() {
|
||||
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/vehicles'),
|
||||
fetch('/api/parts'),
|
||||
fetch('/api/categories')
|
||||
]);
|
||||
|
||||
if (brandsRes.ok && vehiclesRes.ok) {
|
||||
const brands = await brandsRes.json();
|
||||
const vehiclesData = await vehiclesRes.json();
|
||||
const vehicles = vehiclesData.data || vehiclesData;
|
||||
|
||||
// Contar modelos únicos
|
||||
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;
|
||||
if (statsRes.ok) {
|
||||
const s = await statsRes.json();
|
||||
this.stats.brands = s.brands;
|
||||
this.stats.models = s.models;
|
||||
this.stats.vehicles = s.vehicles;
|
||||
this.stats.parts = s.parts;
|
||||
|
||||
const fmt = n => n > 1000 ? Math.floor(n/1000) + 'K+' : n;
|
||||
const brandsEl = document.getElementById('totalBrands');
|
||||
const modelsEl = document.getElementById('totalModels');
|
||||
if (brandsEl) brandsEl.textContent = this.stats.brands;
|
||||
if (modelsEl) modelsEl.textContent = this.stats.models > 1000 ? Math.floor(this.stats.models/1000) + 'K+' : this.stats.models;
|
||||
const partsEl = document.getElementById('totalParts');
|
||||
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) {
|
||||
const partsData = await partsRes.json();
|
||||
// Handle paginated response
|
||||
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 (brandsRes.ok) {
|
||||
// Still needed for brand list rendering
|
||||
await brandsRes.json();
|
||||
}
|
||||
|
||||
if (categoriesRes.ok) {
|
||||
@@ -1144,6 +1138,11 @@ class VehicleDashboard {
|
||||
<h4 class="mb-3">${part.name_es || part.name || 'Sin nombre'}</h4>
|
||||
</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">
|
||||
<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>
|
||||
|
||||
1221
dashboard/demo.html
Normal file
1221
dashboard/demo.html
Normal file
File diff suppressed because it is too large
Load Diff
211
dashboard/login.css
Normal file
211
dashboard/login.css
Normal 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
108
dashboard/login.html
Normal 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">⚙</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
227
dashboard/login.js
Normal 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');
|
||||
};
|
||||
|
||||
})();
|
||||
@@ -21,12 +21,23 @@
|
||||
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 === '/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;
|
||||
}
|
||||
|
||||
var navLinks = [
|
||||
{ label: 'Cat\u00e1logo', href: '/' },
|
||||
{ label: 'Diagramas', href: '/diagramas' },
|
||||
{ label: 'Demo', href: '/demo' },
|
||||
{ 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' }
|
||||
];
|
||||
|
||||
@@ -99,6 +110,16 @@
|
||||
+ '">'
|
||||
+ linksHTML
|
||||
+ '</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>'
|
||||
+ '</header>';
|
||||
|
||||
@@ -106,4 +127,29 @@
|
||||
if (target) {
|
||||
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
418
dashboard/pos.css
Normal 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
113
dashboard/pos.html
Normal 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
413
dashboard/pos.js
Normal 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 + '">×</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();
|
||||
})();
|
||||
1585
dashboard/server.py
1585
dashboard/server.py
File diff suppressed because it is too large
Load Diff
678
dashboard/tienda.css
Normal file
678
dashboard/tienda.css
Normal 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
156
dashboard/tienda.html
Normal 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
187
dashboard/tienda.js
Normal 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);
|
||||
})();
|
||||
1571
docs/API.md
1571
docs/API.md
File diff suppressed because it is too large
Load Diff
449
docs/ARCHITECTURE.md
Normal file
449
docs/ARCHITECTURE.md
Normal 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.
|
||||
84
docs/plans/2026-03-01-captura-partes-design.md
Normal file
84
docs/plans/2026-03-01-captura-partes-design.md
Normal 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
|
||||
130
docs/plans/2026-03-02-pos-cuentas-design.md
Normal file
130
docs/plans/2026-03-02-pos-cuentas-design.md
Normal 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
|
||||
722
docs/plans/2026-03-02-pos-cuentas-plan.md
Normal file
722
docs/plans/2026-03-02-pos-cuentas-plan.md
Normal 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"
|
||||
```
|
||||
242
docs/plans/2026-03-15-saas-aftermarket-design.md
Normal file
242
docs/plans/2026-03-15-saas-aftermarket-design.md
Normal 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
|
||||
```
|
||||
1306
docs/plans/2026-03-15-saas-aftermarket-plan.md
Normal file
1306
docs/plans/2026-03-15-saas-aftermarket-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,3 +5,6 @@ lxml>=4.9.0
|
||||
sqlalchemy>=2.0
|
||||
psycopg2-binary>=2.9
|
||||
flask-sqlalchemy>=3.1
|
||||
PyJWT>=2.8
|
||||
bcrypt>=4.0
|
||||
openpyxl>=3.1
|
||||
|
||||
144
scripts/import_live.py
Normal file
144
scripts/import_live.py
Normal 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
148
scripts/import_phase1.py
Normal 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
414
scripts/import_tecdoc.py
Normal 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 (1950–2027)
|
||||
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()
|
||||
531
scripts/import_tecdoc_parts.py
Normal file
531
scripts/import_tecdoc_parts.py
Normal 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()
|
||||
251
scripts/link_vehicle_parts.py
Normal file
251
scripts/link_vehicle_parts.py
Normal 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
220
scripts/migrate_aftermarket.py
Executable 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")
|
||||
127
scripts/migrate_saas_schema.py
Normal file
127
scripts/migrate_saas_schema.py
Normal 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
43
scripts/run_all_brands.sh
Executable 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"
|
||||
Reference in New Issue
Block a user