1 Commits

Author SHA1 Message Date
7f753376e3 cambios para corregir bug visual de los temas oscuro y blanco 2026-06-24 10:54:33 -06:00
118 changed files with 1374 additions and 11807 deletions

4
.gitignore vendored
View File

@@ -91,7 +91,3 @@ backups/
# Local tools (AWS CLI) # Local tools (AWS CLI)
tools/ tools/
# Rached migration session artifacts (tokens / captures / samples)
rached_*.json
rached_*.txt

View File

@@ -1,140 +0,0 @@
# Auditoría y Mejoras del Sistema Nexus POS — 15 de junio de 2026
## Resumen ejecutivo
Se realizó una revisión completa del sistema Nexus POS enfocada en corregir errores críticos y mayores, eliminar código muerto, cerrar brechas de seguridad menores y estandarizar la experiencia de usuario. También se aplicaron optimizaciones de rendimiento en consultas frecuentes y se mejoró la accesibilidad de los formularios.
- **Rama/base:** `main`
- **Tenant de pruebas:** `tenant_refaccionaria_la_casita` (`tenant_id = 33`)
- **Servicio:** `nexus-pos.service` (puerto 5001)
- **Estado final:** operativo, tests pasando
---
## 1. Problemas críticos corregidos
| Problema | Solución | Archivos principales |
|----------|----------|----------------------|
| `@require_auth` sin paréntesis en `tasks_bp` | Se cambió al decorador correcto | `pos/blueprints/tasks_bp.py` |
| Permiso inexistente `accounting.read` | Se reemplazó por `accounting.view` | `pos/blueprints/accounting_bp.py` |
| `historical_sales.html` no cargaba `api.js` | Se incluyó el helper compartido | `pos/templates/historical_sales.html`, `pos/static/js/api.js` |
| `featureProximamente` no estaba disponible en `customers.js` | Se expuso la función en el módulo | `pos/static/js/customers.js` |
| `pos_engine` usaba `threading` sin importar y la caja sin `FOR UPDATE` | Se agregó `import threading` y lock pesimista de caja | `pos/services/pos_engine.py` |
| Race condition en stock de ventas | Se agregó `SELECT ... FOR UPDATE` sobre `inventory_stock` | `pos/services/pos_engine.py` |
---
## 2. Problemas mayores corregidos
| Problema | Solución | Archivos principales |
|----------|----------|----------------------|
| N+1 en `complete_layaway` | Se cargan todos los items en una sola consulta | `pos/services/pos_engine.py` |
| Race conditions en folios CFDI y números de póliza | Se usó `pg_advisory_xact_lock(hashtext(prefix))` | `pos/services/cfdi_queue.py`, `pos/services/accounting_engine.py` |
| N+1 en flotilla (schedules/history) | Se crearon endpoints bulk `/vehicles/schedules` y `/vehicles/history` | `pos/blueprints/fleet_bp.py`, `pos/static/js/fleet.js` |
| N+1 en dashboard (ventas recientes) y `global_invoice` | Se consolidaron consultas | `pos/blueprints/dashboard_stats_bp.py`, `pos/services/global_invoice.py` |
| `client_secret` de MercadoLibre expuesto en frontend | Se movió el flujo OAuth a backend con endpoint `/connect/init` | `pos/blueprints/marketplace_external_bp.py`, `pos/static/js/marketplace_external.js` |
| Botones/filtros sin handler en config, customers y accounting | Se conectaron eventos y handlers faltantes | `pos/static/js/config.js`, `pos/static/js/customers.js`, `pos/static/js/accounting.v9.js`, `pos/templates/*.html` |
| Botones/filtros sin handler en invoicing | Se implementaron filtros, export CSV y acciones | `pos/static/js/invoicing.js`, `pos/templates/invoicing.html` |
---
## 3. Mejoras de rendimiento
- **Locks atómicos:** los folios de órdenes de servicio, CFDI y pólizas contables ahora se generan bajo `pg_advisory_xact_lock` para evitar duplicados bajo concurrencia.
- **Consultas bulk:** flotilla y dashboard redujeron drásticamente el número de queries al backend.
- **Virtual scroll:** se mantiene en tablas grandes (clientes, programas de mantenimiento, historial) para renderizado eficiente.
- **Cache busting:** se actualizaron los query strings estáticos a `?v=32` y el Service Worker a `v32` para forzar la actualización de assets en navegadores y PWA.
---
## 4. Mejoras de UX y accesibilidad
- **Formularios accesibles:** se agregaron `aria-label`, `id` y `<label>` explícitos en:
- POS: pago mixto, toggle de costo/margen, campos de vehículo del modal de cliente.
- Clientes: búsqueda y filtros.
- Catálogo: búsqueda y filtro de niveles.
- Facturación: búsquedas de facturas, notas de crédito y complementos de pago.
- **Estados de carga consistentes:** se añadió `renderLoadingState()` en `pos-utils.js` con estilo en `pos-ui.css`, y se aplicó en:
- Clientes (`customers.js`)
- Facturación (`invoicing.js`)
- Flotilla (`fleet.js`): vehículos, programas, historial y alertas.
- **Empty states normalizados:** los errores y listas vacías ahora usan `renderEmptyState()` en lugar de mensajes inline inconsistentes.
---
## 5. Limpieza de código
- Se eliminaron las versiones obsoletas `pos/static/js/accounting.js` y `pos/static/js/accounting.min.js`.
- Se consolidó la lógica contable en `pos/static/js/accounting.v9.js`.
- Se removieron `console.log`/`console.error` de depuración de `accounting.v9.js`.
- Se limpió encabezado comentado de `accounting.v9.js`.
---
## 6. Archivos añadidos
```text
pos/migrations/v4.10_fleet_permissions.sql
pos/migrations/v4.7_workshop_business.sql
pos/migrations/v4.8_workshop_permissions.sql
pos/migrations/v4.9_workshop_customers_view.sql
pos/static/js/accounting.v9.js
pos/static/js/api.js
scripts/allocate_existing_customer_payments.py
```
---
## 7. Estadísticas del cambio
```text
54 archivos modificados
+2485 / -1300 líneas aproximadas
```
---
## 8. Validación
```bash
# Sintaxis de JavaScript
node --check pos/static/js/pos-utils.js
node --check pos/static/js/customers.js
node --check pos/static/js/invoicing.js
node --check pos/static/js/fleet.js
node --check pos/static/js/accounting.v9.js
node --check pos/static/js/pos.js
node --check pos/static/js/catalog.js
# Tests de backend
python3 -m pytest pos/tests/test_service_order_integration.py \
pos/tests/test_bulk_import.py \
pos/tests/test_facturapi_service.py -q
# Resultado: 35 passed
```
Servicio `nexus-pos.service` verificado como **activo**.
---
## 9. Instrucciones de despliegue
1. Aplicar migraciones SQL nuevas según el orden numérico:
- `v4.7_workshop_business.sql`
- `v4.8_workshop_permissions.sql`
- `v4.9_workshop_customers_view.sql`
- `v4.10_fleet_permissions.sql`
2. Reiniciar el servicio para cargar cambios de backend:
```bash
sudo systemctl restart nexus-pos.service
```
3. Refrescar la PWA en los navegadores/clientes para que `sw.js` actualice el cache a `v32`.
---
## 10. Notas para el equipo
- Los permisos de flotilla (`fleet.view/create/edit/delete`) ya están sembrados para `owner`/`admin` y `view` para `accountant`/`workshop`.
- El Service Worker se sirve correctamente en `/pos/sw.js` desde `static/pwa/sw.js`.
- El helper global `api()` fue creado para páginas standalone como `historical_sales.html`.
- No se realizaron mutaciones de git no solicitadas; el push a Gitea se hace como parte de esta entrega.

View File

@@ -74,7 +74,7 @@ server {
} }
location = / { location = / {
return 302 /pos/login2; return 302 /pos/login;
} }
location / { location / {

View File

@@ -1,4 +1,4 @@
from flask import Flask, request, redirect, make_response from flask import Flask
from json_provider import OrjsonProvider from json_provider import OrjsonProvider
@@ -10,20 +10,12 @@ def create_app():
from middleware_tenant import resolve_tenant from middleware_tenant import resolve_tenant
app.before_request(resolve_tenant) app.before_request(resolve_tenant)
# NOTE: Page-level routing guards are handled client-side by app-init.js
# using the employee's current permissions; API endpoints enforce their own
# permission checks via @require_auth.
# ─── PWA: Service Worker must be served from /pos/ scope ────── # ─── PWA: Service Worker must be served from /pos/ scope ──────
@app.route('/pos/sw.js') @app.route('/pos/sw.js')
def pos_sw(): def pos_sw():
from flask import send_from_directory, make_response from flask import send_from_directory
response = make_response(send_from_directory('static/pwa', 'sw.js', return send_from_directory('static/pwa', 'sw.js',
mimetype='application/javascript')) mimetype='application/javascript')
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
# Register blueprints # Register blueprints
from blueprints.auth_bp import auth_bp from blueprints.auth_bp import auth_bp
@@ -140,23 +132,11 @@ def create_app():
return send_from_directory('static/pwa', 'icon-192.png', mimetype='image/png') return send_from_directory('static/pwa', 'icon-192.png', mimetype='image/png')
@app.route('/pos/login') @app.route('/pos/login')
def pos_login_legacy():
# Redirect to the new login path to bypass any stale browser/SW cache
# of the old /pos/login page that did not send employee_id.
response = make_response(redirect('/pos/login2'))
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
return response
@app.route('/pos/login2')
def pos_login(): def pos_login():
response = make_response(render_template('login.html', return render_template('login.html',
tenant_id=getattr(g, 'tenant_id', None), tenant_id=getattr(g, 'tenant_id', None),
tenant_name=getattr(g, 'tenant_name', None), tenant_name=getattr(g, 'tenant_name', None),
tenant_subdomain=getattr(g, 'tenant_subdomain', None))) tenant_subdomain=getattr(g, 'tenant_subdomain', None))
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
@app.route('/pos/supplier-catalog') @app.route('/pos/supplier-catalog')
def supplier_catalog_page(): def supplier_catalog_page():
@@ -188,11 +168,7 @@ def create_app():
@app.route('/pos/dashboard') @app.route('/pos/dashboard')
def pos_dashboard(): def pos_dashboard():
response = make_response(render_template('dashboard.html')) return render_template('dashboard.html')
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
@app.route('/pos/config') @app.route('/pos/config')
def pos_config(): def pos_config():
@@ -234,14 +210,6 @@ def create_app():
def pos_historical_sales(): def pos_historical_sales():
return render_template('historical_sales.html') return render_template('historical_sales.html')
@app.route('/pos/remission-notes')
def pos_remission_notes():
response = make_response(render_template('remission_notes.html'))
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
@app.route('/pos/static/<path:filename>') @app.route('/pos/static/<path:filename>')
def pos_static(filename): def pos_static(filename):
return send_from_directory('static', filename) return send_from_directory('static', filename)

View File

@@ -7,10 +7,8 @@ NUMERIC(14,2) in the database.
""" """
import json import json
import csv from datetime import date, datetime
import io from flask import Blueprint, request, jsonify, g
from datetime import date, datetime, timedelta
from flask import Blueprint, request, jsonify, g, Response
from middleware import require_auth from middleware import require_auth
from tenant_db import get_tenant_conn from tenant_db import get_tenant_conn
from services.accounting_engine import create_manual_entry from services.accounting_engine import create_manual_entry
@@ -567,140 +565,21 @@ def balance_sheet():
@accounting_bp.route('/aging', methods=['GET']) @accounting_bp.route('/aging', methods=['GET'])
@require_auth('accounting.view') @require_auth('accounting.view')
def aging_report(): def aging_report():
"""Antiguedad de saldos. """Antiguedad de saldos (accounts receivable aging).
Returns individual credit sales with outstanding balance when type=receivable Groups outstanding credit sales by age:
(default), or purchase orders payable to suppliers when type=payable. - Corriente (not yet due)
- 1-30 dias
- 31-60 dias
- 61-90 dias
- 90+ dias
""" """
report_type = request.args.get('type', 'receivable')
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
rows = []
if report_type == 'payable':
# Accounts payable: purchase orders to suppliers that are not paid/cancelled
cur.execute("""
SELECT po.id, po.supplier_invoice, po.total, po.created_at, po.expected_date,
po.status, s.id, s.name
FROM purchase_orders po
JOIN suppliers s ON s.id = po.supplier_id
WHERE po.status NOT IN ('paid', 'cancelled')
ORDER BY po.created_at DESC
""")
for r in cur.fetchall():
po_id = r[0]
invoice = r[1] or f'OC-{po_id}'
total = float(r[2]) if r[2] else 0
created_at = r[3]
expected_date = r[4]
status = r[5]
vendor_name = r[7]
paid = 0 # TODO: sum supplier payments when that table is added
balance = round(total - paid, 2)
if balance <= 0:
continue
days_overdue = (datetime.now(created_at.tzinfo) - expected_date).days if expected_date and created_at else 0
if days_overdue > 0:
po_status = 'overdue'
label = 'Vencida'
elif paid > 0:
po_status = 'partial'
label = 'Parcial'
else:
po_status = 'pending'
label = 'Pendiente'
rows.append({
'po_id': po_id,
'invoice': invoice,
'vendor_name': vendor_name,
'issue_date': created_at.isoformat() if created_at else None,
'due_date': expected_date.isoformat() if expected_date else None,
'total': total,
'paid': paid,
'balance': balance,
'days_overdue': days_overdue,
'status': po_status,
'status_label': label,
})
else:
# Accounts receivable: credit sales to customers
cur.execute("""
SELECT s.id, s.total, s.created_at, s.status,
c.id, c.name, c.rfc,
COALESCE((SELECT SUM(amount) FROM sale_payments sp WHERE sp.sale_id = s.id), 0) as payments_total
FROM sales s
JOIN customers c ON c.id = s.customer_id
WHERE s.sale_type = 'credit'
AND s.status = 'completed'
ORDER BY s.created_at DESC
""")
for r in cur.fetchall():
sale_id = r[0]
total = float(r[1]) if r[1] else 0
created_at = r[2]
status = r[3]
customer_name = r[5]
payments_total = float(r[7]) if r[7] else 0
paid = payments_total
balance = round(total - paid, 2)
if balance <= 0:
continue
due_date = created_at + timedelta(days=30) if created_at else None
days_overdue = (datetime.now(created_at.tzinfo) - due_date).days if due_date else 0
if days_overdue > 0:
sale_status = 'overdue'
label = 'Vencida'
elif paid > 0:
sale_status = 'partial'
label = 'Parcial'
else:
sale_status = 'pending'
label = 'Vigente'
rows.append({
'sale_id': sale_id,
'invoice': f'VTA-{sale_id}',
'customer_name': customer_name,
'issue_date': created_at.isoformat() if created_at else None,
'due_date': due_date.isoformat() if due_date else None,
'total': total,
'paid': paid,
'balance': balance,
'days_overdue': days_overdue,
'status': sale_status,
'status_label': label,
})
totals = {
'count': len(rows),
'total': round(sum(r['total'] for r in rows), 2),
'paid': round(sum(r['paid'] for r in rows), 2),
'balance': round(sum(r['balance'] for r in rows), 2),
}
cur.close()
conn.close()
return jsonify({'data': rows, 'totals': totals})
@accounting_bp.route('/aging-summary', methods=['GET'])
@require_auth('accounting.view')
def aging_summary():
"""Customer-level accounts receivable summary (used by reports)."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
cur.execute(""" cur.execute("""
SELECT c.id, c.name, c.rfc, c.credit_limit, c.credit_balance, SELECT c.id, c.name, c.rfc, c.credit_limit, c.credit_balance,
s.id as sale_id, s.id as sale_id, s.total, s.created_at,
s.total - COALESCE((SELECT SUM(amount) FROM sale_payments sp WHERE sp.sale_id = s.id), 0) as balance,
s.created_at,
EXTRACT(DAY FROM NOW() - s.created_at)::int as days_outstanding EXTRACT(DAY FROM NOW() - s.created_at)::int as days_outstanding
FROM customers c FROM customers c
JOIN sales s ON s.customer_id = c.id JOIN sales s ON s.customer_id = c.id
@@ -713,11 +592,6 @@ def aging_summary():
customers = {} customers = {}
for r in cur.fetchall(): for r in cur.fetchall():
cust_id = r[0] cust_id = r[0]
balance = round(float(r[6]) if r[6] else 0, 2)
if balance <= 0:
continue
days = r[8] or 0
if cust_id not in customers: if cust_id not in customers:
customers[cust_id] = { customers[cust_id] = {
'id': r[0], 'name': r[1], 'rfc': r[2], 'id': r[0], 'name': r[1], 'rfc': r[2],
@@ -727,25 +601,29 @@ def aging_summary():
'total': 0, 'total': 0,
} }
if days <= 0: amount = float(r[6]) if r[6] else 0
customers[cust_id]['corriente'] += balance days = r[8] or 0
elif days <= 30:
customers[cust_id]['d1_30'] += balance
elif days <= 60:
customers[cust_id]['d31_60'] += balance
elif days <= 90:
customers[cust_id]['d61_90'] += balance
else:
customers[cust_id]['d90_plus'] += balance
customers[cust_id]['total'] += balance if days <= 0:
customers[cust_id]['corriente'] += amount
elif days <= 30:
customers[cust_id]['d1_30'] += amount
elif days <= 60:
customers[cust_id]['d31_60'] += amount
elif days <= 90:
customers[cust_id]['d61_90'] += amount
else:
customers[cust_id]['d90_plus'] += amount
customers[cust_id]['total'] += amount
result = list(customers.values()) result = list(customers.values())
# Round all amounts
for c in result: for c in result:
c['credit_balance'] = round(c['total'], 2)
for key in ('corriente', 'd1_30', 'd31_60', 'd61_90', 'd90_plus', 'total'): for key in ('corriente', 'd1_30', 'd31_60', 'd61_90', 'd90_plus', 'total'):
c[key] = round(c[key], 2) c[key] = round(c[key], 2)
# Totals row
totals = { totals = {
'corriente': round(sum(c['corriente'] for c in result), 2), 'corriente': round(sum(c['corriente'] for c in result), 2),
'd1_30': round(sum(c['d1_30'] for c in result), 2), 'd1_30': round(sum(c['d1_30'] for c in result), 2),
@@ -753,7 +631,6 @@ def aging_summary():
'd61_90': round(sum(c['d61_90'] for c in result), 2), 'd61_90': round(sum(c['d61_90'] for c in result), 2),
'd90_plus': round(sum(c['d90_plus'] for c in result), 2), 'd90_plus': round(sum(c['d90_plus'] for c in result), 2),
'total': round(sum(c['total'] for c in result), 2), 'total': round(sum(c['total'] for c in result), 2),
'count': len(result),
} }
cur.close() cur.close()
@@ -761,161 +638,6 @@ def aging_summary():
return jsonify({'data': result, 'totals': totals}) return jsonify({'data': result, 'totals': totals})
@accounting_bp.route('/aging/export', methods=['GET'])
@require_auth('accounting.view')
def export_aging():
"""Export receivables or payables to CSV/PDF."""
report_type = request.args.get('type', 'receivable')
fmt = request.args.get('format', 'csv').lower()
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
if report_type == 'payable':
# Payables always CSV for now
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(['OC', 'Proveedor', 'Fecha emision', 'Fecha vencimiento',
'Total', 'Pagado', 'Saldo', 'Dias vencido', 'Estado'])
cur.execute("""
SELECT po.id, po.supplier_invoice, po.total, po.created_at, po.expected_date,
po.status, s.name
FROM purchase_orders po
JOIN suppliers s ON s.id = po.supplier_id
WHERE po.status NOT IN ('paid', 'cancelled')
ORDER BY po.created_at DESC
""")
for r in cur.fetchall():
po_id = r[0]
invoice = r[1] or f'OC-{po_id}'
total = float(r[2]) if r[2] else 0
created_at = r[3]
expected = r[4]
status = r[5]
vendor = r[6]
paid = 0
balance = round(total - paid, 2)
days_overdue = (datetime.now(created_at.tzinfo) - expected).days if expected and created_at else 0
status_label = 'Vencida' if days_overdue > 0 else 'Pendiente'
writer.writerow([
invoice, vendor,
created_at.strftime('%Y-%m-%d') if created_at else '',
expected.strftime('%Y-%m-%d') if expected else '',
total, paid, balance, days_overdue, status_label
])
cur.close(); conn.close()
csv_data = output.getvalue()
output.close()
return Response(
'\ufeff' + csv_data,
mimetype='text/csv; charset=utf-8',
headers={'Content-Disposition': f'attachment; filename=cuentas_por_pagar_{date.today().isoformat()}.csv'}
)
# Receivables: CSV or PDF
cur.execute("""
SELECT s.id, s.subtotal, s.tax_total, s.total, s.created_at,
c.name,
COALESCE((SELECT SUM(amount) FROM sale_payments sp WHERE sp.sale_id = s.id), 0) as payments_total
FROM sales s
JOIN customers c ON c.id = s.customer_id
WHERE s.sale_type = 'credit'
AND s.status = 'completed'
ORDER BY s.created_at DESC
""")
rows = []
for r in cur.fetchall():
sale_id = r[0]
subtotal = float(r[1]) if r[1] else 0
tax = float(r[2]) if r[2] else 0
total = float(r[3]) if r[3] else 0
created_at = r[4]
customer_name = r[5]
payments_total = float(r[6]) if r[6] else 0
balance = round(total - payments_total, 2)
if balance <= 0:
continue
rows.append({
'date': created_at.strftime('%d/%m/%Y') if created_at else '',
'customer': customer_name,
'subtotal': subtotal,
'tax': tax,
'total': total,
'balance': balance,
})
cur.close(); conn.close()
if fmt == 'pdf':
from fpdf import FPDF
class ReceivablesPDF(FPDF):
def header(self):
self.set_font('Arial', 'B', 14)
self.cell(0, 10, 'Reporte de Ventas por Cobrar', 0, 1, 'C')
self.set_font('Arial', '', 10)
self.cell(0, 6, f'Generado: {date.today().strftime("%d/%m/%Y")}', 0, 1, 'C')
self.ln(4)
def footer(self):
self.set_y(-15)
self.set_font('Arial', 'I', 8)
self.cell(0, 10, f'Pagina {self.page_no()}', 0, 0, 'C')
pdf = ReceivablesPDF('L', 'mm', 'A4')
pdf.add_page()
pdf.set_font('Arial', 'B', 10)
pdf.set_fill_color(230, 230, 230)
col_widths = [30, 95, 35, 30, 35, 35]
headers = ['FECHA', 'CLIENTE', 'SUBTOTAL', 'IVA', 'TOTAL', 'SALDO']
for i, h in enumerate(headers):
pdf.cell(col_widths[i], 10, h, 1, 0, 'C', True)
pdf.ln()
pdf.set_font('Arial', '', 9)
totals = {'subtotal': 0, 'tax': 0, 'total': 0, 'balance': 0}
for row in rows:
pdf.cell(col_widths[0], 8, row['date'], 1, 0, 'C')
pdf.cell(col_widths[1], 8, row['customer'][:50], 1, 0, 'L')
pdf.cell(col_widths[2], 8, f"{row['subtotal']:.2f}", 1, 0, 'R')
pdf.cell(col_widths[3], 8, f"{row['tax']:.2f}", 1, 0, 'R')
pdf.cell(col_widths[4], 8, f"{row['total']:.2f}", 1, 0, 'R')
pdf.cell(col_widths[5], 8, f"{row['balance']:.2f}", 1, 1, 'R')
totals['subtotal'] += row['subtotal']
totals['tax'] += row['tax']
totals['total'] += row['total']
totals['balance'] += row['balance']
pdf.set_font('Arial', 'B', 9)
pdf.cell(col_widths[0] + col_widths[1], 8, 'TOTAL', 1, 0, 'R', True)
pdf.cell(col_widths[2], 8, f"{totals['subtotal']:.2f}", 1, 0, 'R', True)
pdf.cell(col_widths[3], 8, f"{totals['tax']:.2f}", 1, 0, 'R', True)
pdf.cell(col_widths[4], 8, f"{totals['total']:.2f}", 1, 0, 'R', True)
pdf.cell(col_widths[5], 8, f"{totals['balance']:.2f}", 1, 1, 'R', True)
pdf_bytes = bytes(pdf.output(dest='S'))
return Response(
pdf_bytes,
mimetype='application/pdf',
headers={'Content-Disposition': f'attachment; filename=ventas_por_cobrar_{date.today().isoformat()}.pdf'}
)
# CSV fallback
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(['Fecha', 'Cliente', 'Subtotal', 'IVA', 'Total', 'Saldo'])
for row in rows:
writer.writerow([row['date'], row['customer'], row['subtotal'], row['tax'], row['total'], row['balance']])
csv_data = output.getvalue()
output.close()
return Response(
'\ufeff' + csv_data,
mimetype='text/csv; charset=utf-8',
headers={'Content-Disposition': f'attachment; filename=ventas_por_cobrar_{date.today().isoformat()}.csv'}
)
# ─── Fiscal Periods ──────────────────────────────── # ─── Fiscal Periods ────────────────────────────────
@accounting_bp.route('/periods', methods=['GET']) @accounting_bp.route('/periods', methods=['GET'])
@@ -1022,7 +744,7 @@ def close_period():
@accounting_bp.route('/stats', methods=['GET']) @accounting_bp.route('/stats', methods=['GET'])
@require_auth('accounting.view') @require_auth('accounting.read')
def api_accounting_stats(): def api_accounting_stats():
"""Return counts for tab badges: receivables (asset accounts with balance) and payables (liability accounts with balance).""" """Return counts for tab badges: receivables (asset accounts with balance) and payables (liability accounts with balance)."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)

View File

@@ -5,10 +5,9 @@ import jwt
import bcrypt import bcrypt
import time import time
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from flask import Blueprint, request, jsonify, g, make_response from flask import Blueprint, request, jsonify, g
from config import JWT_SECRET, JWT_ACCESS_EXPIRES, PIN_MAX_ATTEMPTS_PER_MINUTE, PIN_LOCKOUT_THRESHOLD, PIN_LOCKOUT_MINUTES from config import JWT_SECRET, JWT_ACCESS_EXPIRES, PIN_MAX_ATTEMPTS_PER_MINUTE, PIN_LOCKOUT_THRESHOLD, PIN_LOCKOUT_MINUTES
from tenant_db import get_tenant_conn, get_master_conn from tenant_db import get_tenant_conn, get_master_conn
from middleware import require_auth
auth_bp = Blueprint('auth', __name__, url_prefix='/pos/api/auth') auth_bp = Blueprint('auth', __name__, url_prefix='/pos/api/auth')
@@ -60,7 +59,6 @@ def login_pin():
# Subdomain-resolved tenant takes priority over body param # Subdomain-resolved tenant takes priority over body param
tenant_id = getattr(g, 'tenant_id', None) or data.get('tenant_id') tenant_id = getattr(g, 'tenant_id', None) or data.get('tenant_id')
pin = data.get('pin', '') pin = data.get('pin', '')
employee_id = data.get('employee_id')
device_id = data.get('device_id', request.headers.get('X-Device-Id', 'unknown')) device_id = data.get('device_id', request.headers.get('X-Device-Id', 'unknown'))
# Optional: branch_id from the device for PIN search optimization # Optional: branch_id from the device for PIN search optimization
device_branch_id = data.get('branch_id') device_branch_id = data.get('branch_id')
@@ -91,26 +89,39 @@ def login_pin():
matched_employee = None matched_employee = None
if not employee_id: if device_branch_id:
_record_attempt(device_id, False) # Try branch employees first (fast path for known devices)
cur.close()
conn.close()
return jsonify({'error': 'Empleado no seleccionado'}), 400
# Verify the PIN only for the selected employee.
cur.execute(""" cur.execute("""
SELECT e.id, e.name, e.pin, e.role, e.branch_id, e.max_discount_pct SELECT e.id, e.name, e.pin, e.role, e.branch_id, e.max_discount_pct
FROM employees e FROM employees e
WHERE e.id = %s AND e.is_active = true AND e.pin IS NOT NULL WHERE e.is_active = true AND e.pin IS NOT NULL AND e.branch_id = %s
""", (employee_id,)) """, (device_branch_id,))
emp = cur.fetchone() for emp in cur.fetchall():
if emp:
emp_id, emp_name, emp_pin_hash, emp_role, emp_branch, emp_discount = emp emp_id, emp_name, emp_pin_hash, emp_role, emp_branch, emp_discount = emp
if emp_pin_hash and bcrypt.checkpw(pin.encode(), emp_pin_hash.encode()): if emp_pin_hash and bcrypt.checkpw(pin.encode(), emp_pin_hash.encode()):
matched_employee = { matched_employee = {
'id': emp_id, 'name': emp_name, 'role': emp_role, 'id': emp_id, 'name': emp_name, 'role': emp_role,
'branch_id': emp_branch, 'max_discount_pct': float(emp_discount) if emp_discount else 0 'branch_id': emp_branch, 'max_discount_pct': float(emp_discount) if emp_discount else 0
} }
break
if not matched_employee:
# Fallback: check ALL active employees (covers owners, admins, roaming staff)
cur.execute("""
SELECT e.id, e.name, e.pin, e.role, e.branch_id, e.max_discount_pct
FROM employees e
WHERE e.is_active = true AND e.pin IS NOT NULL
""")
employees = cur.fetchall()
for emp in employees:
emp_id, emp_name, emp_pin_hash, emp_role, emp_branch, emp_discount = emp
if emp_pin_hash and bcrypt.checkpw(pin.encode(), emp_pin_hash.encode()):
matched_employee = {
'id': emp_id, 'name': emp_name, 'role': emp_role,
'branch_id': emp_branch, 'max_discount_pct': float(emp_discount) if emp_discount else 0
}
break
if not matched_employee: if not matched_employee:
_record_attempt(device_id, False) _record_attempt(device_id, False)
@@ -146,77 +157,9 @@ def login_pin():
} }
token = jwt.encode(payload, JWT_SECRET, algorithm='HS256') token = jwt.encode(payload, JWT_SECRET, algorithm='HS256')
response = make_response(jsonify({
'token': token,
'employee': matched_employee,
'permissions': permissions
}))
# Cookie used by server-side route guards; JS can also read it for quick checks.
response.set_cookie(
'pos_role', matched_employee['role'],
path='/pos', samesite='Lax', httponly=False
)
return response
@auth_bp.route('/refresh', methods=['POST'])
@require_auth()
def refresh_token():
"""Reissue the JWT with the employee's current permissions from the DB.
This lets permission changes take effect without forcing a full re-login.
The original expiration time is preserved.
"""
auth_header = request.headers.get('Authorization', '')
try:
payload = jwt.decode(auth_header[7:], JWT_SECRET, algorithms=['HS256'])
except jwt.InvalidTokenError:
return jsonify({'error': 'Invalid token'}), 401
tenant_id = payload.get('tenant_id')
employee_id = payload.get('employee_id')
conn = get_tenant_conn(tenant_id)
cur = conn.cursor()
cur.execute(
"""
SELECT e.id, e.name, e.role, e.branch_id, e.max_discount_pct
FROM employees e
WHERE e.id = %s AND e.is_active = true
""",
(employee_id,)
)
emp = cur.fetchone()
if not emp:
cur.close(); conn.close()
return jsonify({'error': 'Employee not found or inactive'}), 404
cur.execute(
"SELECT permission FROM employee_permissions WHERE employee_id = %s",
(emp[0],)
)
permissions = [r[0] for r in cur.fetchall()]
cur.close(); conn.close()
new_payload = {
'tenant_id': tenant_id,
'employee_id': emp[0],
'name': emp[1],
'role': emp[2],
'branch_id': emp[3],
'max_discount_pct': float(emp[4]) if emp[4] else 0,
'permissions': permissions,
'device_id': payload.get('device_id', 'unknown'),
'type': 'pos_access',
'exp': payload.get('exp'),
'iat': datetime.now(timezone.utc),
}
token = jwt.encode(new_payload, JWT_SECRET, algorithm='HS256')
return jsonify({ return jsonify({
'token': token, 'token': token,
'employee': { 'employee': matched_employee,
'id': emp[0], 'name': emp[1], 'role': emp[2],
'branch_id': emp[3], 'max_discount_pct': new_payload['max_discount_pct']
},
'permissions': permissions 'permissions': permissions
}) })
@@ -254,7 +197,7 @@ def list_login_employees(tenant_id=None):
name = row[1] name = row[1]
parts = name.split() parts = name.split()
initials = ''.join([p[0].upper() for p in parts[:2]]) if parts else '?' initials = ''.join([p[0].upper() for p in parts[:2]]) if parts else '?'
role_labels = {'owner': 'Dueño', 'admin': 'Administrador', 'cashier': 'Cajero', 'counter': 'Mostrador', 'warehouse': 'Almacén', 'accountant': 'Contador', 'workshop': 'Taller', 'mechanic': 'Mecánico'} role_labels = {'owner': 'Dueño', 'admin': 'Administrador', 'cashier': 'Cajero', 'warehouse': 'Almacén', 'accountant': 'Contador'}
employees.append({ employees.append({
'id': row[0], 'id': row[0],
'name': name, 'name': name,

View File

@@ -3,26 +3,16 @@
from datetime import datetime from datetime import datetime
from flask import Blueprint, request, jsonify, g from flask import Blueprint, request, jsonify, g
from middleware import require_auth, has_permission from middleware import require_auth
from tenant_db import get_tenant_conn from tenant_db import get_tenant_conn
from services.audit import log_action from services.audit import log_action
cashregister_bp = Blueprint('cashregister', __name__, url_prefix='/pos/api/register') cashregister_bp = Blueprint('cashregister', __name__, url_prefix='/pos/api/register')
# Roles expected to operate a cash register even without the explicit pos.sell permission.
_REGISTER_ROLES = {'owner', 'admin', 'cashier', 'counter'}
def _can_operate_register():
return g.employee_role in _REGISTER_ROLES or 'pos.sell' in g.permissions
@cashregister_bp.route('/open', methods=['POST']) @cashregister_bp.route('/open', methods=['POST'])
@require_auth() @require_auth('pos.sell')
def open_register(): def open_register():
if not _can_operate_register():
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
"""Open a cash register session. """Open a cash register session.
Body: {register_number: int, opening_amount: float} Body: {register_number: int, opening_amount: float}
@@ -94,7 +84,7 @@ def open_register():
@cashregister_bp.route('/current', methods=['GET']) @cashregister_bp.route('/current', methods=['GET'])
@require_auth() @require_auth('pos.sell')
def current_register(): def current_register():
"""Get the current open register for this employee.""" """Get the current open register for this employee."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
@@ -125,10 +115,8 @@ def current_register():
@cashregister_bp.route('/movement', methods=['POST']) @cashregister_bp.route('/movement', methods=['POST'])
@require_auth() @require_auth('pos.sell')
def cash_movement(): def cash_movement():
if not _can_operate_register():
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
"""Record a cash in/out movement with mandatory reason. """Record a cash in/out movement with mandatory reason.
Body: {type: 'in'|'out', amount: float, reason: str} Body: {type: 'in'|'out', amount: float, reason: str}
@@ -285,10 +273,8 @@ def _compute_register_summary(conn, register_id):
@cashregister_bp.route('/cut-x', methods=['GET']) @cashregister_bp.route('/cut-x', methods=['GET'])
@require_auth() @require_auth('pos.sell')
def cut_x(): def cut_x():
if not _can_operate_register():
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
"""Partial cut (corte X): read-only summary without closing the register.""" """Partial cut (corte X): read-only summary without closing the register."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
@@ -315,10 +301,8 @@ def cut_x():
@cashregister_bp.route('/cut-z', methods=['POST']) @cashregister_bp.route('/cut-z', methods=['POST'])
@require_auth() @require_auth('pos.sell')
def cut_z(): def cut_z():
if not _can_operate_register():
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
"""Final cut (corte Z): close the register. """Final cut (corte Z): close the register.
Body: {closing_amount: float} (the amount physically counted in the register) Body: {closing_amount: float} (the amount physically counted in the register)
@@ -395,18 +379,12 @@ def cut_z():
@cashregister_bp.route('/history', methods=['GET']) @cashregister_bp.route('/history', methods=['GET'])
@require_auth() @require_auth('pos.view')
def register_history(): def register_history():
"""List closed registers with summary. """List closed registers with summary.
Query params: date_from, date_to, employee_id, page, per_page Query params: date_from, date_to, employee_id, page, per_page
Permission rules:
- owner/admin and users with pos.view can query any employee.
- Cashiers/counters without pos.view can only query their own registers.
""" """
can_view_all = g.employee_role in ('owner', 'admin') or has_permission('pos.view')
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
@@ -424,9 +402,6 @@ def register_history():
date_to = request.args.get('date_to') date_to = request.args.get('date_to')
employee_id = request.args.get('employee_id') employee_id = request.args.get('employee_id')
if not can_view_all:
employee_id = g.employee_id
if date_from: if date_from:
where_clauses.append("cr.closed_at >= %s") where_clauses.append("cr.closed_at >= %s")
params.append(date_from) params.append(date_from)
@@ -601,89 +576,3 @@ def daily_summary():
'movements_out': movements['out'], 'movements_out': movements['out'],
'registers': registers, 'registers': registers,
}) })
@cashregister_bp.route('/<int:register_id>/sales', methods=['GET'])
@require_auth()
def register_sales(register_id):
"""List the sales associated with a specific cash register (cash cut).
Returns sales details and a summary by payment method. Accessible to
owners/admins, users with pos.view, or the employee who operated the register.
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute(
"SELECT employee_id, branch_id FROM cash_registers WHERE id = %s",
(register_id,)
)
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Register not found'}), 404
register_employee_id, register_branch_id = row
can_view = (
g.employee_role in ('owner', 'admin') or
has_permission('pos.view') or
register_employee_id == g.employee_id
)
if not can_view:
cur.close(); conn.close()
return jsonify({'error': 'Missing permissions: pos.view'}), 403
if g.branch_id and register_branch_id and register_branch_id != g.branch_id:
cur.close(); conn.close()
return jsonify({'error': 'Register belongs to another branch'}), 403
where = "s.register_id = %s"
params = [register_id]
if g.branch_id:
where += " AND s.branch_id = %s"
params.append(g.branch_id)
cur.execute(f"""
SELECT s.id, s.sale_type, s.payment_method, s.subtotal, s.discount_total,
s.tax_total, s.total, s.amount_paid, s.change_given, s.status,
s.created_at, c.name as customer_name, e.name as employee_name
FROM sales s
LEFT JOIN customers c ON s.customer_id = c.id
LEFT JOIN employees e ON s.employee_id = e.id
WHERE {where}
ORDER BY s.created_at DESC
""", params)
sales = []
summary = {'total': 0.0, 'count': 0, 'by_method': {}}
for r in cur.fetchall():
sale = {
'id': r[0], 'sale_type': r[1], 'payment_method': r[2],
'subtotal': float(r[3]) if r[3] else 0,
'discount_total': float(r[4]) if r[4] else 0,
'tax_total': float(r[5]) if r[5] else 0,
'total': float(r[6]) if r[6] else 0,
'amount_paid': float(r[7]) if r[7] else 0,
'change_given': float(r[8]) if r[8] else 0,
'status': r[9], 'created_at': str(r[10]),
'customer_name': r[11], 'employee_name': r[12]
}
sales.append(sale)
if sale['status'] == 'completed':
summary['total'] += sale['total']
summary['count'] += 1
m = sale['payment_method'] or 'Otro'
summary['by_method'][m] = (summary['by_method'].get(m, 0) + sale['total'])
cur.close(); conn.close()
return jsonify({
'register_id': register_id,
'sales': sales,
'summary': {
'total': round(summary['total'], 2),
'count': summary['count'],
'by_method': {k: round(v, 2) for k, v in summary['by_method'].items()}
}
})

View File

@@ -1,7 +1,6 @@
# /home/Autopartes/pos/blueprints/config_bp.py # /home/Autopartes/pos/blueprints/config_bp.py
"""Config blueprint: tenant configuration, branches, theming.""" """Config blueprint: tenant configuration, branches, theming."""
import json
from flask import Blueprint, request, jsonify, g from flask import Blueprint, request, jsonify, g
from middleware import require_auth, has_permission from middleware import require_auth, has_permission
from tenant_db import get_tenant_conn from tenant_db import get_tenant_conn
@@ -9,188 +8,6 @@ from tenant_db import get_tenant_conn
config_bp = Blueprint('config', __name__, url_prefix='/pos/api/config') config_bp = Blueprint('config', __name__, url_prefix='/pos/api/config')
# Default permission set per role. Can be overridden per tenant via role_permissions config.
_DEFAULT_ROLE_PERMISSIONS = {
'owner': [], # owner bypasses permission checks
'admin': ['pos.sell', 'pos.discount', 'pos.cancel', 'pos.view_cost',
'inventory.view', 'inventory.create', 'inventory.edit', 'inventory.adjust', 'inventory.transfer',
'catalog.view', 'catalog.edit',
'customers.view', 'customers.create', 'customers.edit', 'customers.edit_credit',
'invoicing.view', 'invoicing.create',
'reports.view', 'reports.financial',
'config.view', 'config.edit', 'config.edit_prices',
'workshop.view', 'workshop.edit',
'fleet.view', 'fleet.create', 'fleet.edit', 'fleet.delete'],
'cashier': ['pos.sell', 'pos.discount', 'pos.cancel', 'pos.view', 'pos.remission',
'catalog.view',
'inventory.view', 'inventory.create',
'customers.view', 'customers.create',
'workshop.view', 'workshop.edit', 'workshop.add_items',
'invoicing.view', 'invoicing.create', 'invoicing.cancel'],
'counter': ['pos.remission', 'pos.view',
'catalog.view',
'inventory.view', 'inventory.create',
'customers.view', 'customers.create'],
'warehouse': ['inventory.view', 'inventory.create', 'inventory.edit',
'inventory.adjust', 'inventory.transfer', 'catalog.view'],
'accountant': ['accounting.view', 'accounting.create',
'invoicing.view', 'invoicing.create', 'invoicing.cancel',
'reports.view', 'reports.financial',
'customers.view',
'fleet.view'],
'workshop': ['workshop.view', 'workshop.edit', 'workshop.add_items'],
'mechanic': ['workshop.view'],
'sales': ['pos.sell', 'pos.discount', 'pos.view', 'catalog.view',
'customers.view', 'customers.create'],
}
# Statuses used in the workshop kanban. Must stay in sync with workshop.js COLUMNS.
_WORKSHOP_STATUSES = [
'por_revisar', 'en_revision', 'revisada', 'cotizada', 'por_autorizar',
'autorizada', 'autorizacion_parcial', 'en_reparacion', 'reparada',
'por_entregar', 'entregado', 'por_enviar', 'enviado',
'por_facturar', 'facturada', 'por_recolectar', 'cancelada'
]
# Workshop-specific actions configurable per role.
_WORKSHOP_ACTIONS = [
{'key': 'create_order', 'label': 'Crear órdenes'},
{'key': 'edit_order', 'label': 'Editar órdenes'},
{'key': 'delete_order', 'label': 'Eliminar órdenes'},
{'key': 'assign_mechanic', 'label': 'Asignar mecánico'},
{'key': 'add_items', 'label': 'Agregar artículos'},
{'key': 'add_labor', 'label': 'Agregar mano de obra'},
{'key': 'change_status', 'label': 'Cambiar estatus'},
{'key': 'convert_to_sale', 'label': 'Convertir a venta'},
{'key': 'convert_to_remission', 'label': 'Generar nota de remisión'},
{'key': 'view_customer_data', 'label': 'Ver datos del cliente/vehículo'},
{'key': 'view_prices', 'label': 'Ver precios/costos'},
{'key': 'view_notes', 'label': 'Ver bitácora'},
]
# Default workshop permissions per role. Admins see everything; restricted roles only work statuses.
_DEFAULT_WORKSHOP_PERMISSIONS = {
'owner': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS]},
'admin': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS]},
'manager': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS if a['key'] != 'delete_order']},
'counter': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS if a['key'] != 'delete_order']},
'cashier': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS if a['key'] not in ('delete_order', 'convert_to_sale')]},
'workshop': {
'statuses': [s for s in _WORKSHOP_STATUSES if s not in ('por_entregar', 'entregado', 'por_enviar', 'enviado', 'por_recolectar')],
'actions': ['change_status', 'add_labor', 'view_notes']
},
'mechanic': {
'statuses': ['por_revisar', 'en_revision', 'revisada', 'en_reparacion', 'reparada', 'autorizada', 'cancelada'],
'actions': ['change_status', 'add_labor', 'view_notes']
},
}
_AVAILABLE_PERMISSIONS = [
{'module': 'Dashboard', 'permissions': [
{'key': 'dashboard.view', 'label': 'Ver Dashboard'},
]},
{'module': 'Punto de Venta', 'permissions': [
{'key': 'pos.sell', 'label': 'Realizar ventas'},
{'key': 'pos.view', 'label': 'Ver ventas/cotizaciones'},
{'key': 'pos.discount', 'label': 'Aplicar descuentos'},
{'key': 'pos.cancel', 'label': 'Cancelar ventas'},
{'key': 'pos.remission', 'label': 'Notas de remisión'},
{'key': 'pos.view_cost', 'label': 'Ver costos en POS'},
]},
{'module': 'Inventario', 'permissions': [
{'key': 'inventory.view', 'label': 'Ver inventario'},
{'key': 'inventory.create', 'label': 'Crear artículos'},
{'key': 'inventory.edit', 'label': 'Editar artículos'},
{'key': 'inventory.delete', 'label': 'Eliminar artículos'},
{'key': 'inventory.adjust', 'label': 'Ajustar stock'},
{'key': 'inventory.transfer', 'label': 'Transferir entre sucursales'},
{'key': 'inventory.import', 'label': 'Importar artículos masivamente'},
{'key': 'inventory.view_cost', 'label': 'Ver costos'},
]},
{'module': 'Catálogo', 'permissions': [
{'key': 'catalog.view', 'label': 'Ver catálogo'},
{'key': 'catalog.edit', 'label': 'Editar catálogo'},
]},
{'module': 'Clientes', 'permissions': [
{'key': 'customers.view', 'label': 'Ver clientes'},
{'key': 'customers.create', 'label': 'Crear clientes'},
{'key': 'customers.edit', 'label': 'Editar clientes'},
{'key': 'customers.delete', 'label': 'Eliminar clientes'},
{'key': 'customers.edit_credit', 'label': 'Editar límite de crédito'},
]},
{'module': 'Taller', 'permissions': [
{'key': 'workshop.view', 'label': 'Ver módulo Taller'},
{'key': 'workshop.edit', 'label': 'Crear/editar órdenes (deprecado, usar matriz Taller)'},
{'key': 'workshop.add_items', 'label': 'Agregar artículos/mano de obra (deprecado, usar matriz Taller)'},
]},
{'module': 'Facturación', 'permissions': [
{'key': 'invoicing.view', 'label': 'Ver facturas'},
{'key': 'invoicing.create', 'label': 'Crear facturas'},
{'key': 'invoicing.cancel', 'label': 'Cancelar facturas'},
]},
{'module': 'Configuración', 'permissions': [
{'key': 'config.view', 'label': 'Ver configuración'},
{'key': 'config.edit', 'label': 'Editar configuración'},
{'key': 'config.edit_prices', 'label': 'Modificar precios globales'},
]},
{'module': 'Contabilidad', 'permissions': [
{'key': 'accounting.view', 'label': 'Ver contabilidad'},
{'key': 'accounting.create', 'label': 'Crear movimientos contables'},
]},
{'module': 'Reportes', 'permissions': [
{'key': 'reports.view', 'label': 'Ver reportes'},
{'key': 'reports.financial', 'label': 'Reportes financieros'},
]},
{'module': 'Flotillas', 'permissions': [
{'key': 'fleet.view', 'label': 'Ver flotillas'},
{'key': 'fleet.create', 'label': 'Crear flotillas'},
{'key': 'fleet.edit', 'label': 'Editar flotillas'},
{'key': 'fleet.delete', 'label': 'Eliminar flotillas'},
]},
]
def _get_role_permissions(conn, role):
"""Return configured permissions for a role, falling back to defaults."""
cur = conn.cursor()
cur.execute("SELECT value FROM tenant_config WHERE key = 'role_permissions'")
row = cur.fetchone()
cur.close()
if row and row[0]:
try:
configured = json.loads(row[0])
if isinstance(configured, dict) and role in configured:
return list(configured.get(role, []))
except (ValueError, TypeError):
pass
return list(_DEFAULT_ROLE_PERMISSIONS.get(role, []))
def _get_workshop_permissions(conn, role):
"""Return configured workshop permissions for a role, falling back to defaults."""
cur = conn.cursor()
cur.execute("SELECT value FROM tenant_config WHERE key = 'workshop_permissions'")
row = cur.fetchone()
cur.close()
if row and row[0]:
try:
configured = json.loads(row[0])
if isinstance(configured, dict) and role in configured:
return configured[role]
except (ValueError, TypeError):
pass
return _DEFAULT_WORKSHOP_PERMISSIONS.get(role, {'statuses': [], 'actions': []})
def _get_all_workshop_permissions(conn):
"""Return effective workshop permissions for every known role."""
result = {}
for role in _DEFAULT_WORKSHOP_PERMISSIONS:
result[role] = _get_workshop_permissions(conn, role)
return result
@config_bp.route('/branches', methods=['GET']) @config_bp.route('/branches', methods=['GET'])
@require_auth() @require_auth()
def list_branches(): def list_branches():
@@ -333,58 +150,8 @@ def update_branch(branch_id):
return jsonify({'ok': True, 'message': 'Branch updated'}) return jsonify({'ok': True, 'message': 'Branch updated'})
@config_bp.route('/branches/<int:branch_id>', methods=['DELETE'])
@require_auth('config.edit')
def delete_branch(branch_id):
"""Hard-delete a branch. Only owner/admin can delete; main branch cannot be deleted.
Related records keep their data but lose the branch reference; stock and count
rows tied exclusively to the branch are removed.
"""
if g.employee_role not in ('owner', 'admin'):
return jsonify({'error': 'Solo administradores pueden eliminar sucursales'}), 403
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT is_main FROM branches WHERE id = %s", (branch_id,))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Branch not found'}), 404
if row[0]:
cur.close(); conn.close()
return jsonify({'error': 'No se puede eliminar la sucursal principal'}), 403
# Remove branch-specific stock and count rows first.
cur.execute("DELETE FROM inventory_stock WHERE branch_id = %s", (branch_id,))
cur.execute("DELETE FROM inventory_stock_summary WHERE branch_id = %s", (branch_id,))
cur.execute("DELETE FROM physical_counts WHERE branch_id = %s", (branch_id,))
# Nullify every other FK reference back to branches.
cur.execute("""
SELECT c.relname::text AS tbl, a.attname::text AS col
FROM pg_constraint con
JOIN pg_class c ON c.oid = con.conrelid
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = ANY(con.conkey)
WHERE con.confrelid = 'public.branches'::regclass
AND con.contype = 'f'
""")
for tbl, col in cur.fetchall():
if tbl in ('inventory_stock', 'inventory_stock_summary', 'physical_counts'):
continue
cur.execute(f'UPDATE "{tbl}" SET "{col}" = NULL WHERE "{col}" = %s', (branch_id,))
cur.execute("DELETE FROM branches WHERE id = %s", (branch_id,))
conn.commit()
cur.close()
conn.close()
return jsonify({'ok': True, 'message': 'Sucursal eliminada'})
@config_bp.route('/employees', methods=['GET']) @config_bp.route('/employees', methods=['GET'])
@require_auth() @require_auth('config.view')
def list_employees(): def list_employees():
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
@@ -433,7 +200,7 @@ def create_employee():
nxt_name = PLANS[nxt]['name'] if nxt else 'Enterprise' nxt_name = PLANS[nxt]['name'] if nxt else 'Enterprise'
return jsonify({'error': f'Plan limit reached ({limit} employees). Upgrade to {nxt_name}.'}), 403 return jsonify({'error': f'Plan limit reached ({limit} employees). Upgrade to {nxt_name}.'}), 403
valid_roles = ['admin', 'cashier', 'counter', 'warehouse', 'accountant', 'workshop', 'mechanic'] valid_roles = ['admin', 'cashier', 'warehouse', 'accountant']
if data['role'] not in valid_roles: if data['role'] not in valid_roles:
return jsonify({'error': f'role must be one of: {", ".join(valid_roles)}'}), 400 return jsonify({'error': f'role must be one of: {", ".join(valid_roles)}'}), 400
@@ -448,8 +215,26 @@ def create_employee():
data['role'], data.get('branch_id'), data.get('max_discount_pct', 0))) data['role'], data.get('branch_id'), data.get('max_discount_pct', 0)))
emp_id = cur.fetchone()[0] emp_id = cur.fetchone()[0]
# Set default permissions by role (configurable per tenant) # Set default permissions by role
for perm in _get_role_permissions(conn, data['role']): role_permissions = {
'admin': ['pos.sell', 'pos.discount', 'pos.cancel', 'pos.view_cost',
'inventory.view', 'inventory.create', 'inventory.edit', 'inventory.adjust', 'inventory.transfer',
'catalog.view', 'catalog.edit',
'customers.view', 'customers.create', 'customers.edit', 'customers.edit_credit',
'invoicing.view', 'invoicing.create',
'reports.view', 'reports.financial',
'config.view', 'config.edit', 'config.edit_prices'],
'cashier': ['pos.sell', 'pos.discount', 'pos.cancel',
'catalog.view', 'customers.view', 'customers.create'],
'warehouse': ['inventory.view', 'inventory.create', 'inventory.edit',
'inventory.adjust', 'inventory.transfer', 'catalog.view'],
'accountant': ['accounting.view', 'accounting.create',
'invoicing.view', 'invoicing.create', 'invoicing.cancel',
'reports.view', 'reports.financial',
'customers.view'],
}
for perm in role_permissions.get(data['role'], []):
cur.execute( cur.execute(
"INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s) ON CONFLICT DO NOTHING", "INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s) ON CONFLICT DO NOTHING",
(emp_id, perm) (emp_id, perm)
@@ -465,105 +250,6 @@ def create_employee():
return jsonify({'id': emp_id, 'message': 'Employee created'}), 201 return jsonify({'id': emp_id, 'message': 'Employee created'}), 201
@config_bp.route('/role-permissions', methods=['GET'])
@require_auth()
def get_role_permissions_config():
"""Return the configured permissions for each role and the available permission list."""
conn = get_tenant_conn(g.tenant_id)
try:
configured = {}
for role in _DEFAULT_ROLE_PERMISSIONS:
configured[role] = _get_role_permissions(conn, role)
return jsonify({
'roles': configured,
'available': _AVAILABLE_PERMISSIONS,
})
finally:
conn.close()
@config_bp.route('/role-permissions', methods=['PUT'])
@require_auth('config.edit')
def save_role_permissions_config():
"""Save the permission mapping per role. Only owner/admin can edit."""
if g.employee_role not in ('owner', 'admin'):
return jsonify({'error': 'Solo administradores pueden editar permisos de roles'}), 403
data = request.get_json() or {}
if 'roles' not in data:
return jsonify({'error': 'roles object required'}), 400
for role, perms in data['roles'].items():
if role not in _DEFAULT_ROLE_PERMISSIONS:
return jsonify({'error': f'Invalid role: {role}'}), 400
if not isinstance(perms, list):
return jsonify({'error': f'permissions for {role} must be a list'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES ('role_permissions', %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (json.dumps(data['roles']),))
# Apply the new permissions to existing employees of each affected role
for role, perms in data['roles'].items():
cur.execute("DELETE FROM employee_permissions WHERE employee_id IN (SELECT id FROM employees WHERE role = %s)", (role,))
cur.execute("SELECT id FROM employees WHERE role = %s", (role,))
emp_ids = [r[0] for r in cur.fetchall()]
for emp_id in emp_ids:
for perm in perms:
cur.execute(
"INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s) ON CONFLICT DO NOTHING",
(emp_id, perm)
)
conn.commit()
cur.close(); conn.close()
return jsonify({'ok': True, 'updated_roles': list(data['roles'].keys())})
@config_bp.route('/role-permissions/workshop', methods=['GET'])
@require_auth('config.view')
def get_workshop_permissions_config():
"""Return the configured workshop permissions (statuses + actions) per role."""
conn = get_tenant_conn(g.tenant_id)
try:
return jsonify({
'roles': _get_all_workshop_permissions(conn),
'statuses': [{'key': s, 'label': s.replace('_', ' ').title()} for s in _WORKSHOP_STATUSES],
'actions': _WORKSHOP_ACTIONS,
})
finally:
conn.close()
@config_bp.route('/role-permissions/workshop', methods=['PUT'])
@require_auth('config.edit')
def save_workshop_permissions_config():
"""Save the workshop permission mapping per role."""
if g.employee_role not in ('owner', 'admin'):
return jsonify({'error': 'Solo administradores pueden editar permisos de roles'}), 403
data = request.get_json() or {}
if 'roles' not in data:
return jsonify({'error': 'roles object required'}), 400
for role, cfg in data['roles'].items():
if role not in _DEFAULT_WORKSHOP_PERMISSIONS:
return jsonify({'error': f'Invalid role: {role}'}), 400
if not isinstance(cfg, dict):
return jsonify({'error': f'config for {role} must be an object'}), 400
if not isinstance(cfg.get('statuses', []), list) or not isinstance(cfg.get('actions', []), list):
return jsonify({'error': f'statuses/actions for {role} must be lists'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES ('workshop_permissions', %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (json.dumps(data['roles']),))
conn.commit()
cur.close(); conn.close()
return jsonify({'ok': True, 'updated_roles': list(data['roles'].keys())})
@config_bp.route('/employees/<int:emp_id>', methods=['PUT']) @config_bp.route('/employees/<int:emp_id>', methods=['PUT'])
@require_auth('config.edit') @require_auth('config.edit')
def update_employee(emp_id): def update_employee(emp_id):
@@ -609,15 +295,6 @@ def update_employee(emp_id):
params.append(emp_id) params.append(emp_id)
cur.execute(f"UPDATE employees SET {', '.join(updates)} WHERE id = %s", params) cur.execute(f"UPDATE employees SET {', '.join(updates)} WHERE id = %s", params)
# If the role changed, re-sync permissions to match the new role defaults/config.
if 'role' in data:
cur.execute("DELETE FROM employee_permissions WHERE employee_id = %s", (emp_id,))
for perm in _get_role_permissions(conn, data['role']):
cur.execute(
"INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s) ON CONFLICT DO NOTHING",
(emp_id, perm)
)
from services.audit import log_action from services.audit import log_action
log_action(conn, 'EMPLOYEE_UPDATE', 'employee', emp_id, log_action(conn, 'EMPLOYEE_UPDATE', 'employee', emp_id,
new_value={k: v for k, v in data.items() if k != 'pin'}) new_value={k: v for k, v in data.items() if k != 'pin'})
@@ -628,53 +305,6 @@ def update_employee(emp_id):
return jsonify({'ok': True, 'message': 'Employee updated'}) return jsonify({'ok': True, 'message': 'Employee updated'})
@config_bp.route('/employees/<int:emp_id>', methods=['DELETE'])
@require_auth('config.edit')
def delete_employee(emp_id):
"""Hard-delete an employee. Foreign-key references are nulled and
dependent rows (permissions/sessions) are removed. Owners cannot be
deleted via UI to prevent locking out the tenant."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT role FROM employees WHERE id = %s", (emp_id,))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Employee not found'}), 404
if row[0] == 'owner':
cur.close(); conn.close()
return jsonify({'error': 'No se puede eliminar una cuenta de dueno'}), 403
# Resolve every foreign-key column that points back to employees.
cur.execute("""
SELECT c.relname::text AS tbl, a.attname::text AS col
FROM pg_constraint con
JOIN pg_class c ON c.oid = con.conrelid
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = ANY(con.conkey)
WHERE con.confrelid = 'public.employees'::regclass
AND con.contype = 'f'
""")
refs = cur.fetchall()
for tbl, col in refs:
if tbl in ('employee_permissions', 'employee_sessions', 'notification_preferences'):
cur.execute(f"DELETE FROM \"{tbl}\" WHERE \"{col}\" = %s", (emp_id,))
else:
cur.execute(f"UPDATE \"{tbl}\" SET \"{col}\" = NULL WHERE \"{col}\" = %s", (emp_id,))
cur.execute("DELETE FROM employees WHERE id = %s", (emp_id,))
from services.audit import log_action
log_action(conn, 'EMPLOYEE_DELETE', 'employee', emp_id)
conn.commit()
cur.close()
conn.close()
return jsonify({'ok': True, 'message': 'Empleado eliminado'})
@config_bp.route('/currency', methods=['GET']) @config_bp.route('/currency', methods=['GET'])
@require_auth() @require_auth()
def get_currency(): def get_currency():
@@ -759,14 +389,7 @@ def get_business():
'nombre': cfg.get('tenant_nombre', cfg.get('tenant_razon_social', '')), 'nombre': cfg.get('tenant_nombre', cfg.get('tenant_razon_social', '')),
'rfc': cfg.get('tenant_rfc', ''), 'rfc': cfg.get('tenant_rfc', ''),
'regimen_fiscal': cfg.get('tenant_regimen_fiscal', ''), 'regimen_fiscal': cfg.get('tenant_regimen_fiscal', ''),
'cp': cfg.get('tenant_cp', ''),
'direccion': cfg.get('tenant_direccion', ''), 'direccion': cfg.get('tenant_direccion', ''),
'numero_exterior': cfg.get('tenant_numero_exterior', ''),
'numero_interior': cfg.get('tenant_numero_interior', ''),
'colonia': cfg.get('tenant_colonia', ''),
'ciudad': cfg.get('tenant_ciudad', ''),
'municipio': cfg.get('tenant_municipio', ''),
'estado': cfg.get('tenant_estado', ''),
'telefono': cfg.get('tenant_telefono', ''), 'telefono': cfg.get('tenant_telefono', ''),
'email': cfg.get('tenant_email', ''), 'email': cfg.get('tenant_email', ''),
}) })
@@ -782,22 +405,14 @@ def update_business():
'nombre': 'tenant_nombre', 'nombre': 'tenant_nombre',
'rfc': 'tenant_rfc', 'rfc': 'tenant_rfc',
'regimen_fiscal': 'tenant_regimen_fiscal', 'regimen_fiscal': 'tenant_regimen_fiscal',
'cp': 'tenant_cp',
'direccion': 'tenant_direccion', 'direccion': 'tenant_direccion',
'numero_exterior': 'tenant_numero_exterior',
'numero_interior': 'tenant_numero_interior',
'colonia': 'tenant_colonia',
'ciudad': 'tenant_ciudad',
'municipio': 'tenant_municipio',
'estado': 'tenant_estado',
'telefono': 'tenant_telefono', 'telefono': 'tenant_telefono',
'email': 'tenant_email', 'email': 'tenant_email',
# Tax params (also keep cfdi_* aliases in sync) # Tax params
'tax_iva': 'tax_iva', 'tax_iva': 'tax_iva',
'tax_ieps': 'tax_ieps', 'tax_ieps': 'tax_ieps',
'invoice_serie': 'invoice_serie', 'invoice_serie': 'invoice_serie',
'invoice_folio': 'invoice_folio', 'invoice_folio': 'invoice_folio',
'cfdi_serie': 'cfdi_serie',
'default_currency': 'default_currency', 'default_currency': 'default_currency',
'default_payment_method': 'default_payment_method', 'default_payment_method': 'default_payment_method',
} }
@@ -1048,7 +663,7 @@ def update_whatsapp_config():
@config_bp.route('/modules', methods=['GET']) @config_bp.route('/modules', methods=['GET'])
@require_auth() @require_auth('config.view')
def get_modules(): def get_modules():
"""Get enabled modules for this tenant.""" """Get enabled modules for this tenant."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
@@ -1066,7 +681,6 @@ def get_modules():
'marketplace': _bool('module_marketplace'), 'marketplace': _bool('module_marketplace'),
'meli': _bool('module_meli'), 'meli': _bool('module_meli'),
'catalog': _bool('module_catalog'), 'catalog': _bool('module_catalog'),
'workshop': _bool('module_workshop'),
}) })
@@ -1083,7 +697,6 @@ def update_modules():
'module_marketplace': 'true' if data.get('marketplace') else 'false', 'module_marketplace': 'true' if data.get('marketplace') else 'false',
'module_meli': 'true' if data.get('meli') else 'false', 'module_meli': 'true' if data.get('meli') else 'false',
'module_catalog': 'true' if data.get('catalog') else 'false', 'module_catalog': 'true' if data.get('catalog') else 'false',
'module_workshop': 'true' if data.get('workshop') else 'false',
} }
for key, value in settings.items(): for key, value in settings.items():
@@ -1100,78 +713,9 @@ def update_modules():
'whatsapp': data.get('whatsapp'), 'whatsapp': data.get('whatsapp'),
'marketplace': data.get('marketplace'), 'marketplace': data.get('marketplace'),
'meli': data.get('meli'), 'meli': data.get('meli'),
'catalog': data.get('catalog'),
'workshop': data.get('workshop'),
}}) }})
@config_bp.route('/counter-remission', methods=['GET'])
@require_auth()
def get_counter_remission_config():
"""Get counter remission note feature flag for this tenant."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT value FROM tenant_config WHERE key = 'counter_remission_enabled'")
row = cur.fetchone()
cur.close(); conn.close()
return jsonify({'enabled': str(row[0]).lower() == 'true' if row else False})
@config_bp.route('/counter-remission', methods=['PUT'])
@require_auth('config.edit')
def update_counter_remission_config():
"""Enable/disable counter remission notes for this tenant."""
data = request.get_json() or {}
enabled = 'true' if data.get('enabled') else 'false'
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", ('counter_remission_enabled', enabled))
conn.commit()
cur.close(); conn.close()
return jsonify({'enabled': enabled == 'true'})
@config_bp.route('/sales-settings', methods=['GET'])
@require_auth('pos.view')
def get_sales_settings():
"""Get sales-related settings (zero-price sales, negative stock)."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT key, value FROM tenant_config WHERE key IN ('allow_zero_price_sales', 'allow_negative_stock')")
rows = {k: v for k, v in cur.fetchall()}
cur.close(); conn.close()
# Default allow_zero_price_sales to true to avoid breaking existing tenants.
allow = str(rows.get('allow_zero_price_sales', 'true')).lower() in ('true', '1', 'yes')
# Default allow_negative_stock to false (safer).
neg = str(rows.get('allow_negative_stock', 'false')).lower() in ('true', '1', 'yes')
return jsonify({'allow_zero_price_sales': allow, 'allow_negative_stock': neg})
@config_bp.route('/sales-settings', methods=['PUT'])
@require_auth('config.edit')
def update_sales_settings():
"""Update sales-related settings."""
data = request.get_json() or {}
allow = 'true' if data.get('allow_zero_price_sales') else 'false'
neg = 'true' if data.get('allow_negative_stock') else 'false'
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", ('allow_zero_price_sales', allow))
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", ('allow_negative_stock', neg))
conn.commit()
cur.close(); conn.close()
return jsonify({'allow_zero_price_sales': allow == 'true', 'allow_negative_stock': neg == 'true'})
@config_bp.route('/onboarding-status', methods=['GET']) @config_bp.route('/onboarding-status', methods=['GET'])
@require_auth('pos.view') @require_auth('pos.view')
def get_onboarding_status(): def get_onboarding_status():
@@ -1201,102 +745,3 @@ def set_onboarding_status():
cur.close() cur.close()
conn.close() conn.close()
return jsonify({'completed': completed == 'true'}) return jsonify({'completed': completed == 'true'})
# ─── Receipt / Ticket Customization ────────────────────────────────────────
RECEIPT_CONFIG_KEYS = [
'receipt_logo',
'receipt_store_name',
'receipt_tagline',
'receipt_rfc',
'receipt_address',
'receipt_phone',
'receipt_footer',
'receipt_thanks_message',
'receipt_show_logo',
'receipt_show_rfc',
'receipt_show_address',
'receipt_show_phone',
'receipt_show_iva_breakdown',
'receipt_show_payment_details',
'receipt_show_employee',
'receipt_paper_width',
]
@config_bp.route('/receipt', methods=['GET'])
@require_auth('pos.view')
def get_receipt_config():
"""Get receipt customization settings."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute(
"SELECT key, value FROM tenant_config WHERE key = ANY(%s)",
(RECEIPT_CONFIG_KEYS,)
)
rows = {row[0]: row[1] for row in cur.fetchall()}
cur.close()
conn.close()
def _bool(key, default=False):
v = rows.get(key, 'true' if default else 'false')
return str(v).lower() == 'true'
return jsonify({
'logo': rows.get('receipt_logo', ''),
'store_name': rows.get('receipt_store_name', ''),
'tagline': rows.get('receipt_tagline', ''),
'rfc': rows.get('receipt_rfc', ''),
'address': rows.get('receipt_address', ''),
'phone': rows.get('receipt_phone', ''),
'footer': rows.get('receipt_footer', ''),
'thanks_message': rows.get('receipt_thanks_message', 'Gracias por su compra!'),
'show_logo': _bool('receipt_show_logo', True),
'show_rfc': _bool('receipt_show_rfc', True),
'show_address': _bool('receipt_show_address', False),
'show_phone': _bool('receipt_show_phone', False),
'show_iva_breakdown': _bool('receipt_show_iva_breakdown', True),
'show_payment_details': _bool('receipt_show_payment_details', True),
'show_employee': _bool('receipt_show_employee', False),
'paper_width': rows.get('receipt_paper_width', '80') or '80',
})
@config_bp.route('/receipt', methods=['PUT'])
@require_auth('config.edit')
def update_receipt_config():
"""Update receipt customization settings."""
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
settings = {
'receipt_logo': data.get('logo', ''),
'receipt_store_name': data.get('store_name', ''),
'receipt_tagline': data.get('tagline', ''),
'receipt_rfc': data.get('rfc', ''),
'receipt_address': data.get('address', ''),
'receipt_phone': data.get('phone', ''),
'receipt_footer': data.get('footer', ''),
'receipt_thanks_message': data.get('thanks_message', 'Gracias por su compra!'),
'receipt_show_logo': 'true' if data.get('show_logo') else 'false',
'receipt_show_rfc': 'true' if data.get('show_rfc') else 'false',
'receipt_show_address': 'true' if data.get('show_address') else 'false',
'receipt_show_phone': 'true' if data.get('show_phone') else 'false',
'receipt_show_iva_breakdown': 'true' if data.get('show_iva_breakdown') else 'false',
'receipt_show_payment_details': 'true' if data.get('show_payment_details') else 'false',
'receipt_show_employee': 'true' if data.get('show_employee') else 'false',
'receipt_paper_width': str(data.get('paper_width', '80') or '80'),
}
for key, value in settings.items():
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (key, value))
conn.commit()
cur.close()
conn.close()
return jsonify({'message': 'Receipt configuration updated'})

View File

@@ -10,20 +10,10 @@ from services.audit import log_action
customers_bp = Blueprint('customers', __name__, url_prefix='/pos/api/customers') customers_bp = Blueprint('customers', __name__, url_prefix='/pos/api/customers')
def _can_view_customers():
"""Cashiers, counter and workshop employees need customer access for POS/service flows."""
return g.employee_role == 'owner' or 'customers.view' in g.permissions or g.employee_role in ('cashier', 'counter', 'workshop', 'mechanic')
def _can_create_customer():
"""Cashiers, counter and workshop employees can create customers on the fly."""
return g.employee_role == 'owner' or 'customers.create' in g.permissions or g.employee_role in ('cashier', 'counter', 'workshop', 'mechanic')
# ─── Customer CRUD ───────────────────────────── # ─── Customer CRUD ─────────────────────────────
@customers_bp.route('', methods=['GET']) @customers_bp.route('', methods=['GET'])
@require_auth() @require_auth('customers.view')
def list_customers(): def list_customers():
"""Search/list customers. Supports autocomplete-style search by name, RFC, phone. """Search/list customers. Supports autocomplete-style search by name, RFC, phone.
@@ -33,8 +23,6 @@ def list_customers():
per_page: items per page (default 50, max 200) per_page: items per page (default 50, max 200)
branch_id: filter by branch (default: current user's branch) branch_id: filter by branch (default: current user's branch)
""" """
if not _can_view_customers():
return jsonify({'error': 'Missing permissions: customers.view'}), 403
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
@@ -42,24 +30,10 @@ def list_customers():
per_page = min(int(request.args.get('per_page', 50)), 200) per_page = min(int(request.args.get('per_page', 50)), 200)
search = request.args.get('q', '').strip() search = request.args.get('q', '').strip()
branch_id = request.args.get('branch_id') branch_id = request.args.get('branch_id')
price_tier = request.args.get('price_tier', '').strip()
status = request.args.get('status', '').strip().lower()
where_clauses = [] where_clauses = ["c.is_active = true"]
params = [] params = []
if status == 'inactive':
where_clauses.append("c.is_active = false")
elif status == 'overdue':
where_clauses.append(
"c.is_active = true AND c.credit_limit > 0 AND c.credit_balance > c.credit_limit"
)
elif status == 'all':
pass # no is_active filter
else:
# Default to active customers for backwards compatibility
where_clauses.append("c.is_active = true")
if branch_id: if branch_id:
where_clauses.append("c.branch_id = %s") where_clauses.append("c.branch_id = %s")
params.append(int(branch_id)) params.append(int(branch_id))
@@ -68,20 +42,8 @@ def list_customers():
"(c.name ILIKE %s OR c.rfc ILIKE %s OR c.phone ILIKE %s OR c.razon_social ILIKE %s)" "(c.name ILIKE %s OR c.rfc ILIKE %s OR c.phone ILIKE %s OR c.razon_social ILIKE %s)"
) )
params.extend([f'%{search}%'] * 4) params.extend([f'%{search}%'] * 4)
if price_tier:
# Support numeric tier or Spanish labels
tier_map = {'taller': 2, 'mostrador': 1, 'mayoreo': 3}
tier_val = tier_map.get(price_tier.lower())
if tier_val is None:
try:
tier_val = int(price_tier)
except ValueError:
tier_val = None
if tier_val in (1, 2, 3):
where_clauses.append("c.price_tier = %s")
params.append(tier_val)
where = " AND ".join(where_clauses) if where_clauses else "true" where = " AND ".join(where_clauses)
# Count # Count
cur.execute(f"SELECT count(*) FROM customers c WHERE {where}", params) cur.execute(f"SELECT count(*) FROM customers c WHERE {where}", params)
@@ -92,8 +54,7 @@ def list_customers():
SELECT c.id, c.name, c.rfc, c.razon_social, c.phone, c.email, SELECT c.id, c.name, c.rfc, c.razon_social, c.phone, c.email,
c.address, c.cp, c.address, c.cp,
c.price_tier, c.credit_limit, c.credit_balance, c.vehicle_info, c.price_tier, c.credit_limit, c.credit_balance, c.vehicle_info,
c.branch_id, c.is_active, c.created_at, c.branch_id
(SELECT MAX(s.created_at) FROM sales s WHERE s.customer_id = c.id) AS last_purchase
FROM customers c FROM customers c
WHERE {where} WHERE {where}
ORDER BY c.name ORDER BY c.name
@@ -110,9 +71,6 @@ def list_customers():
'credit_balance': float(r[10]) if r[10] else 0, 'credit_balance': float(r[10]) if r[10] else 0,
'vehicle_info': r[11], 'vehicle_info': r[11],
'branch_id': r[12], 'branch_id': r[12],
'is_active': r[13],
'created_at': str(r[14]) if r[14] else None,
'last_purchase': str(r[15]) if r[15] else None,
}) })
cur.close() cur.close()
@@ -126,10 +84,8 @@ def list_customers():
@customers_bp.route('/<int:customer_id>', methods=['GET']) @customers_bp.route('/<int:customer_id>', methods=['GET'])
@require_auth() @require_auth('customers.view')
def get_customer(customer_id): def get_customer(customer_id):
if not _can_view_customers():
return jsonify({'error': 'Missing permissions: customers.view'}), 403
"""Get customer details with credit info, vehicle history, and recent purchases.""" """Get customer details with credit info, vehicle history, and recent purchases."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
@@ -179,71 +135,19 @@ def get_customer(customer_id):
float(customer['credit_limit']) - float(customer['credit_balance']), 2 float(customer['credit_limit']) - float(customer['credit_balance']), 2
) )
# Fleet vehicles assigned to this customer
cur.execute("""
SELECT id, plate, vin, make, model, year, current_mileage, color, owner_name, is_active, created_at
FROM fleet_vehicles
WHERE customer_id = %s
ORDER BY is_active DESC, created_at DESC
""", (customer_id,))
customer['fleet_vehicles'] = []
for r in cur.fetchall():
customer['fleet_vehicles'].append({
'id': r[0], 'plate': r[1], 'vin': r[2], 'make': r[3], 'model': r[4],
'year': r[5], 'current_mileage': r[6], 'color': r[7], 'owner_name': r[8],
'is_active': r[9], 'created_at': str(r[10]) if r[10] else None,
})
cur.close() cur.close()
conn.close() conn.close()
return jsonify(customer) return jsonify(customer)
@customers_bp.route('/<int:customer_id>/purchases', methods=['GET'])
@require_auth()
def get_customer_purchases(customer_id):
if not _can_view_customers():
return jsonify({'error': 'Missing permissions: customers.view'}), 403
"""Return full purchase history for a customer."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT s.id, s.total, s.payment_method, s.sale_type, s.status, s.created_at,
e.name as employee_name
FROM sales s
LEFT JOIN employees e ON s.employee_id = e.id
WHERE s.customer_id = %s
ORDER BY s.created_at DESC
""", (customer_id,))
purchases = []
for r in cur.fetchall():
purchases.append({
'id': r[0],
'total': float(r[1]) if r[1] else 0,
'payment_method': r[2],
'sale_type': r[3],
'status': r[4],
'created_at': str(r[5]),
'employee_name': r[6],
})
cur.close()
conn.close()
return jsonify({'data': purchases})
@customers_bp.route('', methods=['POST']) @customers_bp.route('', methods=['POST'])
@require_auth() @require_auth('customers.create')
def create_customer(): def create_customer():
"""Create a new customer. """Create a new customer.
Body: {name, rfc, razon_social, regimen_fiscal, uso_cfdi, cp, email, Body: {name, rfc, razon_social, regimen_fiscal, uso_cfdi, cp, email,
phone, address, price_tier, credit_limit, vehicle_info} phone, address, price_tier, credit_limit, vehicle_info}
""" """
if not _can_create_customer():
return jsonify({'error': 'Missing permissions: customers.create'}), 403
data = request.get_json() or {} data = request.get_json() or {}
if not data.get('name'): if not data.get('name'):
return jsonify({'error': 'name is required'}), 400 return jsonify({'error': 'name is required'}), 400
@@ -336,57 +240,9 @@ def update_customer(customer_id):
return jsonify({'message': 'Customer updated'}) return jsonify({'message': 'Customer updated'})
@customers_bp.route('/<int:customer_id>', methods=['DELETE'])
@require_auth('customers.delete')
def delete_customer(customer_id):
"""Hard-delete a customer.
Related records (sales, service orders, vehicles, etc.) keep their data but
lose the customer reference. Layaways for this customer are removed because
they require a customer_id.
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT id FROM customers WHERE id = %s", (customer_id,))
if not cur.fetchone():
cur.close(); conn.close()
return jsonify({'error': 'Customer not found'}), 404
# Remove layaway dependencies first, then the layaways themselves
cur.execute("DELETE FROM layaway_items WHERE layaway_id IN (SELECT id FROM layaways WHERE customer_id = %s)", (customer_id,))
cur.execute("DELETE FROM layaway_payments WHERE layaway_id IN (SELECT id FROM layaways WHERE customer_id = %s)", (customer_id,))
cur.execute("DELETE FROM layaways WHERE customer_id = %s", (customer_id,))
# Nullify every other FK reference back to customers.
cur.execute("""
SELECT c.relname::text AS tbl, a.attname::text AS col
FROM pg_constraint con
JOIN pg_class c ON c.oid = con.conrelid
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = ANY(con.conkey)
WHERE con.confrelid = 'public.customers'::regclass
AND con.contype = 'f'
""")
refs = cur.fetchall()
for tbl, col in refs:
# Cascade-delete tables and already-handled layaways are skipped.
if tbl in ('customer_activities', 'customer_tag_assignments', 'loyalty_points', 'loyalty_redemptions', 'layaways'):
continue
cur.execute(f'UPDATE "{tbl}" SET "{col}" = NULL WHERE "{col}" = %s', (customer_id,))
cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,))
log_action(conn, 'CUSTOMER_DELETE', 'customer', customer_id)
conn.commit()
cur.close()
conn.close()
return jsonify({'message': 'Cliente eliminado'})
@customers_bp.route('/<int:customer_id>/statement', methods=['GET']) @customers_bp.route('/<int:customer_id>/statement', methods=['GET'])
@require_auth() @require_auth('customers.view')
def customer_statement(customer_id): def customer_statement(customer_id):
if not _can_view_customers():
return jsonify({'error': 'Missing permissions: customers.view'}), 403
"""Account statement: sales (invoices), payments, running balance. """Account statement: sales (invoices), payments, running balance.
Query params: Query params:
@@ -485,10 +341,8 @@ def customer_statement(customer_id):
@customers_bp.route('/<int:customer_id>/vehicles', methods=['GET']) @customers_bp.route('/<int:customer_id>/vehicles', methods=['GET'])
@require_auth() @require_auth('customers.view')
def customer_vehicles(customer_id): def customer_vehicles(customer_id):
if not _can_view_customers():
return jsonify({'error': 'Missing permissions: customers.view'}), 403
"""Get customer's vehicle list with last purchases per vehicle. """Get customer's vehicle list with last purchases per vehicle.
Vehicle info is stored as JSONB in customers.vehicle_info: Vehicle info is stored as JSONB in customers.vehicle_info:
@@ -585,32 +439,6 @@ def record_customer_payment(customer_id):
UPDATE customers SET credit_balance = %s WHERE id = %s UPDATE customers SET credit_balance = %s WHERE id = %s
""", (new_balance, customer_id)) """, (new_balance, customer_id))
# Allocate the customer payment to oldest unpaid credit sales so the
# aging report and per-sale balances reflect the remaining debt.
remaining = amount
cur.execute("""
SELECT s.id, s.total - COALESCE(SUM(sp.amount), 0) as balance
FROM sales s
LEFT JOIN sale_payments sp ON sp.sale_id = s.id
WHERE s.customer_id = %s
AND s.sale_type = 'credit'
AND s.status = 'completed'
GROUP BY s.id, s.total, s.created_at
HAVING s.total - COALESCE(SUM(sp.amount), 0) > 0
ORDER BY s.created_at
""", (customer_id,))
for sale_id, balance in cur.fetchall():
if remaining <= 0:
break
pay = round(min(remaining, float(balance)), 2)
cur.execute("""
INSERT INTO sale_payments
(sale_id, register_id, method, amount, reference)
VALUES (%s, %s, %s, %s, %s)
""", (sale_id, register_id, payment_method, pay,
f'Abono cliente #{customer_id}'))
remaining = round(remaining - pay, 2)
# Record cash movement on register if cash payment # Record cash movement on register if cash payment
if register_id and payment_method == 'efectivo': if register_id and payment_method == 'efectivo':
cur.execute(""" cur.execute("""

View File

@@ -118,70 +118,3 @@ def get_employee_stats():
finally: finally:
cur.close() cur.close()
conn.close() conn.close()
@dashboard_stats_bp.route('/credit-alerts', methods=['GET'])
@require_auth()
def credit_alerts():
"""Credit sales that are overdue or due within the next 7 days."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
try:
cur.execute("""
SELECT s.id,
c.name as customer_name,
s.total,
s.created_at,
s.created_at + INTERVAL '30 days' as due_date,
COALESCE(SUM(sp.amount), 0) as paid
FROM sales s
JOIN customers c ON c.id = s.customer_id
LEFT JOIN sale_payments sp ON sp.sale_id = s.id
WHERE s.sale_type = 'credit'
AND s.status = 'completed'
GROUP BY s.id, c.name, s.total, s.created_at
HAVING s.total - COALESCE(SUM(sp.amount), 0) > 0
AND s.created_at + INTERVAL '30 days' <= NOW() + INTERVAL '30 days'
ORDER BY due_date
LIMIT 50
""")
rows = []
now = datetime.utcnow()
for r in cur.fetchall():
sale_id = r[0]
customer_name = r[1]
total = float(r[2]) if r[2] else 0
paid = float(r[5]) if r[5] else 0
balance = round(total - paid, 2)
created_at = r[3]
due_date = r[4]
days_until_due = (due_date.replace(tzinfo=None) - now).days if due_date else None
if days_until_due is None:
continue
status = 'overdue' if days_until_due < 0 else ('due_soon' if days_until_due <= 7 else 'current')
label = 'Vencida' if status == 'overdue' else ('Por vencer' if status == 'due_soon' else 'Al corriente')
rows.append({
'sale_id': sale_id,
'folio': f'VTA-{sale_id}',
'customer_name': customer_name,
'issue_date': created_at.isoformat() if created_at else None,
'due_date': due_date.isoformat() if due_date else None,
'days_until_due': days_until_due,
'total': total,
'paid': paid,
'balance': balance,
'status': status,
'status_label': label,
})
return jsonify({
'data': rows,
'overdue_count': sum(1 for r in rows if r['status'] == 'overdue'),
'due_soon_count': sum(1 for r in rows if r['status'] == 'due_soon'),
})
finally:
cur.close()
conn.close()

View File

@@ -13,7 +13,7 @@ fleet_bp = Blueprint('fleet', __name__, url_prefix='/pos/api/fleet')
# ─── Vehicles CRUD ───────────────────────────── # ─── Vehicles CRUD ─────────────────────────────
@fleet_bp.route('/vehicles', methods=['GET']) @fleet_bp.route('/vehicles', methods=['GET'])
@require_auth('fleet.view') @require_auth()
def list_vehicles(): def list_vehicles():
"""List fleet vehicles with pagination and search. """List fleet vehicles with pagination and search.
@@ -83,7 +83,7 @@ def list_vehicles():
@fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['GET']) @fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['GET'])
@require_auth('fleet.view') @require_auth()
def get_vehicle(vehicle_id): def get_vehicle(vehicle_id):
"""Vehicle detail with maintenance schedules and recent logs.""" """Vehicle detail with maintenance schedules and recent logs."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
@@ -155,128 +155,16 @@ def get_vehicle(vehicle_id):
return jsonify(vehicle) return jsonify(vehicle)
@fleet_bp.route('/vehicles/schedules', methods=['GET'])
@require_auth('fleet.view')
def list_all_schedules():
"""Return all active maintenance schedules joined with vehicle data.
Replaces the N+1 pattern of fetching schedules per vehicle.
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
where_clauses = ["v.is_active = true", "s.is_active = true"]
params = []
if g.branch_id:
where_clauses.append("v.branch_id = %s")
params.append(g.branch_id)
vehicle_ids = request.args.get('vehicle_ids', '').strip()
if vehicle_ids:
ids = [int(x) for x in vehicle_ids.split(',') if x.strip().isdigit()]
if ids:
where_clauses.append("v.id = ANY(%s)")
params.append(ids)
where = " AND ".join(where_clauses)
cur.execute(f"""
SELECT s.id, s.vehicle_id, s.maintenance_type, s.interval_km,
s.interval_months, s.last_done_at, s.last_done_km,
s.next_due_at, s.next_due_km, s.notes,
v.plate, v.make, v.model, v.current_mileage, v.color
FROM fleet_maintenance_schedules s
JOIN fleet_vehicles v ON v.id = s.vehicle_id
WHERE {where}
ORDER BY v.plate, s.next_due_at NULLS LAST, s.next_due_km NULLS LAST
""", params)
schedules = []
for r in cur.fetchall():
schedules.append({
'id': r[0], 'vehicle_id': r[1], 'maintenance_type': r[2],
'interval_km': r[3], 'interval_months': r[4],
'last_done_at': str(r[5]) if r[5] else None, 'last_done_km': r[6],
'next_due_at': str(r[7]) if r[7] else None, 'next_due_km': r[8],
'notes': r[9],
'vehicle': {
'plate': r[10], 'make': r[11], 'model': r[12],
'current_mileage': r[13], 'color': r[14]
}
})
cur.close(); conn.close()
return jsonify({'data': schedules})
@fleet_bp.route('/vehicles/history', methods=['GET'])
@require_auth('fleet.view')
def list_all_history():
"""Return recent maintenance logs for all vehicles joined with vehicle data.
Replaces the N+1 pattern of fetching logs per vehicle.
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
limit = min(int(request.args.get('limit', 200)), 500)
where_clauses = ["v.is_active = true"]
params = [limit]
if g.branch_id:
where_clauses.append("v.branch_id = %s")
params.append(g.branch_id)
vehicle_ids = request.args.get('vehicle_ids', '').strip()
if vehicle_ids:
ids = [int(x) for x in vehicle_ids.split(',') if x.strip().isdigit()]
if ids:
where_clauses.append("v.id = ANY(%s)")
params.append(ids)
where = " AND ".join(where_clauses)
cur.execute(f"""
SELECT l.id, l.vehicle_id, l.schedule_id, l.maintenance_type,
l.mileage_at, l.cost, l.parts_used, l.notes, l.created_at,
e.name as employee_name,
v.plate, v.make, v.model, v.color
FROM fleet_maintenance_logs l
JOIN fleet_vehicles v ON v.id = l.vehicle_id
LEFT JOIN employees e ON l.employee_id = e.id
WHERE {where}
ORDER BY l.created_at DESC
LIMIT %s
""", params)
logs = []
for r in cur.fetchall():
logs.append({
'id': r[0], 'vehicle_id': r[1], 'schedule_id': r[2],
'maintenance_type': r[3], 'mileage_at': r[4],
'cost': float(r[5]) if r[5] else 0, 'parts_used': r[6],
'notes': r[7], 'created_at': str(r[8]) if r[8] else None,
'employee_name': r[9],
'vehicle': {'plate': r[10], 'make': r[11], 'model': r[12], 'color': r[13]}
})
cur.close(); conn.close()
return jsonify({'data': logs})
@fleet_bp.route('/vehicles', methods=['POST']) @fleet_bp.route('/vehicles', methods=['POST'])
@require_auth('fleet.create') @require_auth()
def create_vehicle(): def create_vehicle():
"""Create a fleet vehicle. """Create a fleet vehicle.
Body: {customer_id, plate, make, model, year, current_mileage, fuel_type, color, owner_name, notes} Body: {plate, vin, make, model, year, current_mileage, fuel_type, color, owner_name, notes}
Brand and model are required; plate is optional; VIN is not used.
""" """
data = request.get_json() or {} data = request.get_json() or {}
if not data.get('make') or not data.get('model'): if not data.get('plate') and not data.get('vin'):
return jsonify({'error': 'Marca y modelo son obligatorios'}), 400 return jsonify({'error': 'plate or vin is required'}), 400
if not data.get('customer_id'):
return jsonify({'error': 'El vehiculo debe estar asignado a un cliente'}), 400
branch_id = data.get('branch_id', g.branch_id) branch_id = data.get('branch_id', g.branch_id)
@@ -284,23 +172,17 @@ def create_vehicle():
cur = conn.cursor() cur = conn.cursor()
try: try:
cur.execute("SELECT name FROM customers WHERE id = %s", (data['customer_id'],))
cust = cur.fetchone()
if not cust:
cur.close(); conn.close()
return jsonify({'error': 'Cliente no encontrado'}), 404
cur.execute(""" cur.execute("""
INSERT INTO fleet_vehicles INSERT INTO fleet_vehicles
(branch_id, customer_id, plate, make, model, year, (branch_id, plate, vin, make, model, year,
current_mileage, fuel_type, color, owner_name, notes) current_mileage, fuel_type, color, owner_name, notes)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING id RETURNING id
""", ( """, (
branch_id, data['customer_id'], data.get('plate'), branch_id, data.get('plate'), data.get('vin'),
data.get('make'), data.get('model'), data.get('year'), data.get('make'), data.get('model'), data.get('year'),
data.get('current_mileage', 0), data.get('fuel_type', 'gasolina'), data.get('current_mileage', 0), data.get('fuel_type', 'gasolina'),
data.get('color'), data.get('owner_name') or cust[0], data.get('notes'), data.get('color'), data.get('owner_name'), data.get('notes'),
)) ))
vehicle_id = cur.fetchone()[0] vehicle_id = cur.fetchone()[0]
@@ -319,7 +201,7 @@ def create_vehicle():
@fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['PUT']) @fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['PUT'])
@require_auth('fleet.edit') @require_auth()
def update_vehicle(vehicle_id): def update_vehicle(vehicle_id):
"""Update vehicle fields including mileage. """Update vehicle fields including mileage.
@@ -363,7 +245,7 @@ def update_vehicle(vehicle_id):
@fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['DELETE']) @fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['DELETE'])
@require_auth('fleet.delete') @require_auth()
def deactivate_vehicle(vehicle_id): def deactivate_vehicle(vehicle_id):
"""Soft-delete: set is_active = false.""" """Soft-delete: set is_active = false."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
@@ -384,7 +266,7 @@ def deactivate_vehicle(vehicle_id):
# ─── Maintenance Schedules ───────────────────────────── # ─── Maintenance Schedules ─────────────────────────────
@fleet_bp.route('/vehicles/<int:vehicle_id>/schedules', methods=['GET']) @fleet_bp.route('/vehicles/<int:vehicle_id>/schedules', methods=['GET'])
@require_auth('fleet.view') @require_auth()
def list_schedules(vehicle_id): def list_schedules(vehicle_id):
"""Maintenance schedules for a vehicle.""" """Maintenance schedules for a vehicle."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
@@ -417,7 +299,7 @@ def list_schedules(vehicle_id):
@fleet_bp.route('/vehicles/<int:vehicle_id>/schedules', methods=['POST']) @fleet_bp.route('/vehicles/<int:vehicle_id>/schedules', methods=['POST'])
@require_auth('fleet.create') @require_auth()
def create_schedule(vehicle_id): def create_schedule(vehicle_id):
"""Create maintenance schedule for a vehicle. """Create maintenance schedule for a vehicle.
@@ -462,7 +344,7 @@ def create_schedule(vehicle_id):
# ─── Maintenance Logs ───────────────────────────── # ─── Maintenance Logs ─────────────────────────────
@fleet_bp.route('/vehicles/<int:vehicle_id>/log', methods=['POST']) @fleet_bp.route('/vehicles/<int:vehicle_id>/log', methods=['POST'])
@require_auth('fleet.create') @require_auth()
def record_maintenance(vehicle_id): def record_maintenance(vehicle_id):
"""Record maintenance done. Updates schedule next_due if schedule_id provided. """Record maintenance done. Updates schedule next_due if schedule_id provided.
@@ -545,7 +427,7 @@ def record_maintenance(vehicle_id):
# ─── Alerts ───────────────────────────── # ─── Alerts ─────────────────────────────
@fleet_bp.route('/alerts', methods=['GET']) @fleet_bp.route('/alerts', methods=['GET'])
@require_auth('fleet.view') @require_auth()
def fleet_alerts(): def fleet_alerts():
"""Vehicles with overdue maintenance (next_due_at < NOW() or next_due_km < current_mileage).""" """Vehicles with overdue maintenance (next_due_at < NOW() or next_due_km < current_mileage)."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
@@ -590,7 +472,7 @@ def fleet_alerts():
# ─── Stats ───────────────────────────── # ─── Stats ─────────────────────────────
@fleet_bp.route('/stats', methods=['GET']) @fleet_bp.route('/stats', methods=['GET'])
@require_auth('fleet.view') @require_auth()
def fleet_stats(): def fleet_stats():
"""Fleet summary: total vehicles, overdue count, upcoming this month, total cost this month.""" """Fleet summary: total vehicles, overdue count, upcoming this month, total cost this month."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)

View File

@@ -103,13 +103,8 @@ def list_items():
# branch_id no longer filters inventory rows (shared catalog). # branch_id no longer filters inventory rows (shared catalog).
# It is used only to show per-branch stock. # It is used only to show per-branch stock.
if search: if search:
# Search also matches alternate / alias SKUs. where_clauses.append("(i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s)")
where_clauses.append( params.extend([f'%{search}%', f'%{search}%', f'%{search}%'])
"(i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s "
"OR EXISTS (SELECT 1 FROM inventory_sku_aliases a "
"WHERE a.inventory_id = i.id AND a.is_active = true AND a.sku ILIKE %s))"
)
params.extend([f'%{search}%', f'%{search}%', f'%{search}%', f'%{search}%'])
if category: if category:
where_clauses.append("i.category_id = %s") where_clauses.append("i.category_id = %s")
params.append(int(category)) params.append(int(category))
@@ -426,7 +421,7 @@ def bulk_import_items():
'skip' ignores missing compat; 'reject' requires all compat. 'skip' ignores missing compat; 'reject' requires all compat.
Expected CSV columns (case-insensitive): Expected CSV columns (case-insensitive):
sku/part_number, name, brand, price, stock, cost, sku/part_number, name, brand, price, stock, cost,
sku_secondary, description, category, make, model, year, engine, engine_code location, description, category, make, model, year, engine, engine_code
Optional compat columns: make, model, year, engine, engine_code Optional compat columns: make, model, year, engine, engine_code
""" """
from services.qwen_fitment import get_vehicle_fitment from services.qwen_fitment import get_vehicle_fitment
@@ -450,17 +445,8 @@ def bulk_import_items():
try: try:
ext = os.path.splitext(file.filename)[1].lower() ext = os.path.splitext(file.filename)[1].lower()
if ext == '.csv': if ext == '.csv':
raw = file.stream.read() stream = io.TextIOWrapper(file.stream, encoding='utf-8-sig')
decoded = None reader = csv.DictReader(stream)
for enc in ('utf-8-sig', 'cp1252', 'latin-1'):
try:
decoded = raw.decode(enc)
break
except UnicodeDecodeError:
continue
if decoded is None:
return jsonify({'error': 'Unable to detect CSV encoding. Please save the file as UTF-8.'}), 400
reader = csv.DictReader(io.StringIO(decoded))
rows = list(reader) rows = list(reader)
elif ext in ('.xls', '.xlsx', '.xlsm'): elif ext in ('.xls', '.xlsx', '.xlsm'):
try: try:
@@ -504,7 +490,6 @@ def bulk_import_items():
'marca': 'brand', 'precio': 'price', 'costo': 'cost', 'marca': 'brand', 'precio': 'price', 'costo': 'cost',
'cantidad': 'stock', 'existencia': 'stock', 'inventario': 'stock', 'cantidad': 'stock', 'existencia': 'stock', 'inventario': 'stock',
'ubicacion': 'location', 'categoria': 'category', 'ubicacion': 'location', 'categoria': 'category',
'sku_secundario': 'sku_secondary', 'sku_alt': 'sku_secondary', 'sku_alternativo': 'sku_secondary',
'fabricante': 'make', 'vehiculo': 'make', 'auto': 'make', 'fabricante': 'make', 'vehiculo': 'make', 'auto': 'make',
'modelo': 'model', 'anio': 'year', 'ano': 'year', 'modelo': 'model', 'anio': 'year', 'ano': 'year',
'motor': 'engine', 'codigo_motor': 'engine_code', 'motor': 'engine', 'codigo_motor': 'engine_code',
@@ -535,10 +520,6 @@ def bulk_import_items():
db_name = db_name_row[0] if db_name_row else None db_name = db_name_row[0] if db_name_row else None
mcur.close(); mconn.close() mcur.close(); mconn.close()
# Pre-fetch category name -> id mapping
cur.execute("SELECT id, name FROM categories")
category_map = {str(r[1]).strip().lower(): r[0] for r in cur.fetchall()}
for row_num, row in enumerate(rows, start=1): for row_num, row in enumerate(rows, start=1):
part_number = str(row.get('part_number', '')).strip() part_number = str(row.get('part_number', '')).strip()
name = str(row.get('name', '')).strip() name = str(row.get('name', '')).strip()
@@ -567,10 +548,7 @@ def bulk_import_items():
cost = _to_decimal(row.get('cost'), 0) cost = _to_decimal(row.get('cost'), 0)
location = str(row.get('location', '')).strip() location = str(row.get('location', '')).strip()
description = str(row.get('description', '')).strip() description = str(row.get('description', '')).strip()
category_name = str(row.get('category', '')).strip() category = str(row.get('category', '')).strip()
category_id = category_map.get(category_name.lower()) if category_name else None
if category_name and category_id is None:
warnings.append(f'Row {row_num}: categoria "{category_name}" no encontrada')
# Check if item already exists (catalog is shared across branches) # Check if item already exists (catalog is shared across branches)
cur.execute("SELECT id FROM inventory WHERE part_number = %s", (part_number,)) cur.execute("SELECT id FROM inventory WHERE part_number = %s", (part_number,))
@@ -588,10 +566,10 @@ def bulk_import_items():
price_1 = CASE WHEN %s > 0 THEN %s ELSE price_1 END, price_1 = CASE WHEN %s > 0 THEN %s ELSE price_1 END,
location = COALESCE(NULLIF(%s,''), location), location = COALESCE(NULLIF(%s,''), location),
description = COALESCE(NULLIF(%s,''), description), description = COALESCE(NULLIF(%s,''), description),
category_id = COALESCE(%s, category_id) category = COALESCE(NULLIF(%s,''), category)
WHERE id = %s WHERE id = %s
""", """,
(name, brand, cost, cost, price_1, price_1, location, description, category_id, item_id) (name, brand, cost, cost, price_1, price_1, location, description, category, item_id)
) )
was_inserted = False was_inserted = False
# Record stock adjustment for existing item if stock > 0 # Record stock adjustment for existing item if stock > 0
@@ -604,11 +582,11 @@ def bulk_import_items():
cur.execute( cur.execute(
""" """
INSERT INTO inventory INSERT INTO inventory
(part_number, barcode, name, brand, cost, price_1, location, description, category_id, unit) (part_number, barcode, name, brand, cost, price_1, location, description, category, unit)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id RETURNING id
""", """,
(part_number, barcode, name, brand, cost, price_1, location, description, category_id, 'PZA') (part_number, barcode, name, brand, cost, price_1, location, description, category, 'PZA')
) )
item_id = cur.fetchone()[0] item_id = cur.fetchone()[0]
was_inserted = True was_inserted = True
@@ -621,20 +599,7 @@ def bulk_import_items():
created_ids.append(item_id) created_ids.append(item_id)
created += 1 created += 1
# ---------- 2. Secondary SKU alias ---------- # ---------- 2. Vehicle compatibility ----------
sku_secondary = str(row.get('sku_secondary', '')).strip()
if sku_secondary:
cur.execute(
"""
INSERT INTO inventory_sku_aliases (inventory_id, sku, label)
VALUES (%s, %s, %s)
ON CONFLICT DO NOTHING
""",
(item_id, sku_secondary, 'Secundario')
)
conn.commit()
# ---------- 3. Vehicle compatibility ----------
make = str(row.get('make', '')).strip() make = str(row.get('make', '')).strip()
model = str(row.get('model', '')).strip() model = str(row.get('model', '')).strip()
year_str = str(row.get('year', '')).strip() year_str = str(row.get('year', '')).strip()
@@ -2306,22 +2271,6 @@ def list_inventory_subcategories(category_id):
conn.close() conn.close()
@inventory_bp.route('/categories/all', methods=['GET'])
@require_auth()
def list_all_inventory_categories():
"""Return all active categories (flat, with parent_id) for selectors."""
conn = get_tenant_conn(g.tenant_id)
try:
cur = conn.cursor()
cur.execute(
"SELECT id, name, parent_id FROM categories WHERE is_active = true ORDER BY name"
)
rows = cur.fetchall()
return jsonify({'categories': [{'id': r[0], 'name': r[1], 'parent_id': r[2]} for r in rows]})
finally:
conn.close()
# ─── Global Tier Discounts ─────────────────────── # ─── Global Tier Discounts ───────────────────────
@inventory_bp.route('/tier-discounts', methods=['GET']) @inventory_bp.route('/tier-discounts', methods=['GET'])

View File

@@ -12,7 +12,6 @@ from flask import Blueprint, g, jsonify, request
from middleware import require_auth from middleware import require_auth
from services import facturapi_service from services import facturapi_service
from services.audit import log_action from services.audit import log_action
from services.facturapi_service import FacturapiError
from services.cfdi_facturapi_builder import ( from services.cfdi_facturapi_builder import (
build_egreso_payload, build_egreso_payload,
build_ingreso_payload, build_ingreso_payload,
@@ -44,16 +43,9 @@ def _get_issuer_config(cur, branch_id=None):
result = { result = {
"rfc": config.get("tenant_rfc", ""), "rfc": config.get("tenant_rfc", ""),
"razon_social": config.get("tenant_razon_social", ""), "razon_social": config.get("tenant_razon_social", ""),
"regimen_fiscal": config.get("cfdi_regimen_fiscal") or config.get("tenant_regimen_fiscal", "601"), "regimen_fiscal": config.get("cfdi_regimen_fiscal", "601"),
"cp": config.get("tenant_cp", "00000"), "cp": config.get("tenant_cp", "00000"),
"direccion": config.get("tenant_direccion", ""), "serie": config.get("cfdi_serie", "A"),
"exterior": config.get("tenant_numero_exterior", ""),
"interior": config.get("tenant_numero_interior", ""),
"colonia": config.get("tenant_colonia", ""),
"ciudad": config.get("tenant_ciudad", ""),
"municipio": config.get("tenant_municipio", ""),
"estado": config.get("tenant_estado", ""),
"serie": config.get("cfdi_serie") or config.get("invoice_serie", "A"),
"facturapi_key": config.get("cfdi_facturapi_key", ""), "facturapi_key": config.get("cfdi_facturapi_key", ""),
"facturapi_org_id": config.get("cfdi_facturapi_org_id", ""), "facturapi_org_id": config.get("cfdi_facturapi_org_id", ""),
} }
@@ -493,7 +485,7 @@ def get_sale_pdf(sale_id):
@invoicing_bp.route("/stats", methods=["GET"]) @invoicing_bp.route("/stats", methods=["GET"])
@require_auth("invoicing.view") @require_auth("invoicing.read")
def api_invoicing_stats(): def api_invoicing_stats():
"""Return counts for tab badges: invoices, credit notes, payment complements, cancellations.""" """Return counts for tab badges: invoices, credit notes, payment complements, cancellations."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
@@ -675,7 +667,6 @@ def facturapi_setup():
(result["org_id"],), (result["org_id"],),
) )
if result.get("api_key"):
cur.execute( cur.execute(
""" """
INSERT INTO tenant_config (key, value) INSERT INTO tenant_config (key, value)
@@ -691,30 +682,18 @@ def facturapi_setup():
cur.close() cur.close()
conn.close() conn.close()
status_conn = get_tenant_conn(g.tenant_id) return jsonify(
try: {
status_cur = status_conn.cursor() "org_id": result["org_id"],
status = facturapi_service.get_org_status(_get_issuer_config(status_cur)) "message": "Facturapi organization created. Complete pending steps in Facturapi dashboard.",
status["org_id"] = result["org_id"] }
if not result.get("legal_updated"): )
status["error"] = status.get("error") or "Datos fiscales no pudieron configurarse automáticamente. Configúralos en el dashboard de Facturapi."
status_cur.close()
status_conn.close()
except Exception:
status_conn.close()
raise
return jsonify(status)
except ValueError as e: except ValueError as e:
conn.rollback() conn.rollback()
cur.close() cur.close()
conn.close() conn.close()
return jsonify({"error": str(e)}), 400 return jsonify({"error": str(e)}), 400
except FacturapiError as e:
conn.rollback()
cur.close()
conn.close()
return jsonify({"error": str(e)}), 400
except Exception as e: except Exception as e:
conn.rollback() conn.rollback()
cur.close() cur.close()

View File

@@ -24,8 +24,6 @@ Routes:
POST /pos/api/marketplace-ext/webhook/meli POST /pos/api/marketplace-ext/webhook/meli
""" """
import urllib.parse
from flask import Blueprint, request, jsonify, g from flask import Blueprint, request, jsonify, g
from middleware import require_auth, has_permission from middleware import require_auth, has_permission
from tenant_db import get_tenant_conn, get_master_conn from tenant_db import get_tenant_conn, get_master_conn
@@ -83,49 +81,6 @@ def get_config():
conn.close() conn.close()
@marketplace_ext_bp.route("/connect/init", methods=["POST"])
@require_auth()
def init_meli_connect():
"""Store client credentials server-side and return the MercadoLibre auth URL.
The frontend no longer keeps the client_secret in localStorage.
"""
err = _require_meli_manage()
if err:
return err
data = request.get_json() or {}
client_id = data.get("client_id", "").strip()
client_secret = data.get("client_secret", "").strip()
category = data.get("category", "").strip()
shipping = data.get("shipping", "").strip()
if not client_id or not client_secret:
return jsonify({"error": "client_id and client_secret required"}), 400
base = _get_public_base_url().rstrip("/")
redirect_uri = f"{base}/pos/marketplace-external/callback"
conn = get_tenant_conn(g.tenant_id)
try:
meli_svc.save_meli_config(conn, {
"meli_client_id": client_id,
"meli_client_secret": client_secret,
"meli_default_category_id": category,
"meli_shipping_mode": shipping,
})
finally:
conn.close()
auth_url = (
"https://auth.mercadolibre.com.mx/authorization?response_type=code"
f"&client_id={urllib.parse.quote(client_id)}"
f"&redirect_uri={urllib.parse.quote(redirect_uri)}"
"&scope=read+write+offline_access"
)
return jsonify({"auth_url": auth_url, "redirect_uri": redirect_uri})
@marketplace_ext_bp.route("/connect", methods=["POST"]) @marketplace_ext_bp.route("/connect", methods=["POST"])
@require_auth() @require_auth()
def connect_meli(): def connect_meli():
@@ -135,21 +90,12 @@ def connect_meli():
data = request.get_json() or {} data = request.get_json() or {}
code = data.get("code") code = data.get("code")
client_id = data.get("client_id")
client_secret = data.get("client_secret")
redirect_uri = data.get("redirect_uri", "") redirect_uri = data.get("redirect_uri", "")
if not code: if not code or not client_id or not client_secret:
return jsonify({"error": "code required"}), 400 return jsonify({"error": "code, client_id and client_secret required"}), 400
conn = get_tenant_conn(g.tenant_id)
try:
cfg = meli_svc.get_meli_config(conn)
client_id = data.get("client_id") or cfg.get("meli_client_id")
client_secret = data.get("client_secret") or cfg.get("meli_client_secret")
finally:
conn.close()
if not client_id or not client_secret:
return jsonify({"error": "ML credentials not configured"}), 400
try: try:
token_data = MeliService.exchange_code(code, client_id, client_secret, redirect_uri) token_data = MeliService.exchange_code(code, client_id, client_secret, redirect_uri)

View File

@@ -13,8 +13,7 @@ from middleware import require_auth, has_permission
from tenant_db import get_tenant_conn from tenant_db import get_tenant_conn
from services.pos_engine import ( from services.pos_engine import (
process_sale, cancel_sale, calculate_totals, process_sale, cancel_sale, calculate_totals,
get_price_for_customer, get_margin_info, get_price_for_customer, get_margin_info
create_remission_note, pay_pending_sale
) )
from services.inventory_engine import get_stock from services.inventory_engine import get_stock
from services.audit import log_action from services.audit import log_action
@@ -23,53 +22,6 @@ from config import JWT_SECRET
pos_bp = Blueprint('pos', __name__, url_prefix='/pos/api') pos_bp = Blueprint('pos', __name__, url_prefix='/pos/api')
def _tenant_allows_negative_stock(conn):
"""Return True if the tenant explicitly allows selling below zero stock."""
cur = conn.cursor()
cur.execute("SELECT value FROM tenant_config WHERE key = 'allow_negative_stock'")
row = cur.fetchone()
cur.close()
return row is not None and str(row[0]).lower() in ('true', '1', 'yes')
def _tenant_allows_zero_price(conn):
"""Return True if the tenant allows selling items at $0. Defaults to True."""
cur = conn.cursor()
cur.execute("SELECT value FROM tenant_config WHERE key = 'allow_zero_price_sales'")
row = cur.fetchone()
cur.close()
return str(row[0]).lower() in ('true', '1', 'yes') if row else True
def _validate_zero_price(conn, items):
"""Raise ValueError if any item line would be <= $0 and the tenant forbids it."""
if _tenant_allows_zero_price(conn):
return
for item in items:
unit_price = float(item.get('unit_price', 0) or 0)
quantity = float(item.get('quantity', 1) or 1)
discount_pct = float(item.get('discount_pct', 0) or 0)
line_total = unit_price * quantity * (1 - discount_pct / 100)
if line_total <= 0:
name = item.get('name') or item.get('part_number') or item.get('inventory_id')
raise ValueError(f"No está permitido vender artículos en $0 ({name})")
def _validate_stock_availability(conn, items, branch_id):
"""Raise ValueError if stock is insufficient and the tenant forbids negative stock."""
if _tenant_allows_negative_stock(conn):
return
for item in items:
inv_id = item.get('inventory_id')
qty = int(item.get('quantity', 1) or 1)
if not inv_id:
continue
available = get_stock(conn, inv_id, branch_id)
if available < qty:
name = item.get('name') or item.get('part_number') or inv_id
raise ValueError(f'Sin stock suficiente para {name}. Disponible: {available}, solicitado: {qty}')
def _enrich_items(cur, items, customer_id=None): def _enrich_items(cur, items, customer_id=None):
"""Look up inventory data for items that lack unit_price/tax_rate. """Look up inventory data for items that lack unit_price/tax_rate.
@@ -151,11 +103,20 @@ def create_sale():
data = request.get_json() or {} data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
# Verify stock availability per item for the active branch
branch_id = data.get('branch_id', g.branch_id) branch_id = data.get('branch_id', g.branch_id)
for item in data.get('items', []):
inv_id = item.get('inventory_id')
qty = int(item.get('quantity', 1))
if inv_id:
available = get_stock(conn, inv_id, branch_id)
if available < qty:
conn.close()
return jsonify({
'error': f'Insufficient stock for item {inv_id}. Available: {available}, requested: {qty}'
}), 400
try: try:
_validate_stock_availability(conn, data.get('items', []), branch_id)
_validate_zero_price(conn, data.get('items', []))
sale = process_sale(conn, data) sale = process_sale(conn, data)
conn.commit() conn.commit()
conn.close() conn.close()
@@ -170,80 +131,8 @@ def create_sale():
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
@pos_bp.route('/sales/remission', methods=['POST'])
@require_auth('pos.remission')
def create_remission():
"""Create a counter remission note (pending payment, reserved stock)."""
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
branch_id = data.get('branch_id', g.branch_id)
try:
_validate_stock_availability(conn, data.get('items', []), branch_id)
_validate_zero_price(conn, data.get('items', []))
sale = create_remission_note(conn, {
'tenant_id': g.tenant_id,
'branch_id': branch_id,
'customer_id': data.get('customer_id'),
'items': data.get('items', []),
'notes': data.get('notes'),
'register_id': data.get('register_id'),
'currency': data.get('currency', 'MXN'),
'exchange_rate': data.get('exchange_rate'),
'courier_id': data.get('courier_id'),
})
conn.commit()
conn.close()
return jsonify(sale), 201
except ValueError as e:
conn.rollback()
conn.close()
return jsonify({'error': str(e)}), 400
except Exception as e:
conn.rollback()
conn.close()
return jsonify({'error': str(e)}), 500
@pos_bp.route('/sales/<int:sale_id>/pay', methods=['POST'])
@require_auth('pos.sell')
def pay_sale(sale_id):
"""Pay a pending counter remission note.
Body: {
payment_method: 'efectivo' | 'transferencia' | 'tarjeta' | 'mixto',
amount_paid: float,
payment_details: [{method, amount, reference}],
register_id: int,
reference: str
}
"""
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
try:
sale = pay_pending_sale(conn, sale_id, {
'payment_method': data.get('payment_method', 'efectivo'),
'amount_paid': data.get('amount_paid', 0),
'payment_details': data.get('payment_details', []),
'register_id': data.get('register_id'),
'reference': data.get('reference', ''),
})
conn.commit()
conn.close()
return jsonify(sale), 200
except ValueError as e:
conn.rollback()
conn.close()
return jsonify({'error': str(e)}), 400
except Exception as e:
conn.rollback()
conn.close()
return jsonify({'error': str(e)}), 500
@pos_bp.route('/sales', methods=['GET']) @pos_bp.route('/sales', methods=['GET'])
@require_auth() @require_auth('pos.view')
def list_sales(): def list_sales():
"""List sales with filters. """List sales with filters.
@@ -257,13 +146,6 @@ def list_sales():
page: int (default 1) page: int (default 1)
per_page: int (default 50, max 200) per_page: int (default 50, max 200)
""" """
sale_type = request.args.get('sale_type')
is_remission = sale_type == 'counter_remission'
can_view_all = g.employee_role == 'owner' or has_permission('pos.view')
can_view_remissions = is_remission and (has_permission('pos.sell') or has_permission('pos.remission'))
if not (can_view_all or can_view_remissions):
return jsonify({'error': 'Missing permissions: pos.view'}), 403
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
@@ -278,10 +160,6 @@ def list_sales():
employee_id = request.args.get('employee_id') employee_id = request.args.get('employee_id')
customer_id = request.args.get('customer_id') customer_id = request.args.get('customer_id')
status = request.args.get('status') status = request.args.get('status')
sale_type = request.args.get('sale_type')
q = request.args.get('q')
customer_q = request.args.get('customer')
courier_id = request.args.get('courier_id')
register_id = request.args.get('register_id') register_id = request.args.get('register_id')
if date_from: if date_from:
@@ -299,18 +177,6 @@ def list_sales():
if status: if status:
where_clauses.append("s.status = %s") where_clauses.append("s.status = %s")
params.append(status) params.append(status)
if sale_type:
where_clauses.append("s.sale_type = %s")
params.append(sale_type)
if q:
where_clauses.append("(s.id::text ILIKE %s OR c.name ILIKE %s)")
params.extend([f'%{q}%', f'%{q}%'])
if customer_q:
where_clauses.append("c.name ILIKE %s")
params.append(f'%{customer_q}%')
if courier_id:
where_clauses.append("s.courier_id = %s")
params.append(int(courier_id))
if register_id: if register_id:
where_clauses.append("s.register_id = %s") where_clauses.append("s.register_id = %s")
params.append(int(register_id)) params.append(int(register_id))
@@ -329,14 +195,12 @@ def list_sales():
SELECT s.id, s.branch_id, s.customer_id, s.employee_id, s.register_id, SELECT s.id, s.branch_id, s.customer_id, s.employee_id, s.register_id,
s.sale_type, s.payment_method, s.subtotal, s.discount_total, s.sale_type, s.payment_method, s.subtotal, s.discount_total,
s.tax_total, s.total, s.amount_paid, s.change_given, s.tax_total, s.total, s.amount_paid, s.change_given,
s.status, s.created_at, s.courier_id, s.status, s.created_at,
e.name as employee_name, e.name as employee_name,
c.name as customer_name, c.name as customer_name
co.name as courier_name
FROM sales s FROM sales s
LEFT JOIN employees e ON s.employee_id = e.id LEFT JOIN employees e ON s.employee_id = e.id
LEFT JOIN customers c ON s.customer_id = c.id LEFT JOIN customers c ON s.customer_id = c.id
LEFT JOIN couriers co ON s.courier_id = co.id
WHERE {where} WHERE {where}
ORDER BY s.created_at DESC ORDER BY s.created_at DESC
LIMIT %s OFFSET %s LIMIT %s OFFSET %s
@@ -355,9 +219,7 @@ def list_sales():
'amount_paid': float(r[11]) if r[11] else 0, 'amount_paid': float(r[11]) if r[11] else 0,
'change_given': float(r[12]) if r[12] else 0, 'change_given': float(r[12]) if r[12] else 0,
'status': r[13], 'created_at': str(r[14]), 'status': r[13], 'created_at': str(r[14]),
'courier_id': r[15], 'employee_name': r[15], 'customer_name': r[16],
'employee_name': r[16], 'customer_name': r[17],
'courier_name': r[18],
}) })
cur.close() cur.close()
@@ -370,75 +232,6 @@ def list_sales():
}) })
@pos_bp.route('/sales/recent', methods=['GET'])
@require_auth('pos.view')
def recent_sales():
"""Return recent sales with their items in a single response.
Query params:
date_from: YYYY-MM-DD (defaults to today)
date_to: YYYY-MM-DD (defaults to today)
limit: int (default 10, max 50)
"""
from datetime import date
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
date_from = request.args.get('date_from') or str(date.today())
date_to = request.args.get('date_to') or date_from
limit = min(int(request.args.get('limit', 10)), 50)
where_clauses = [
"s.created_at >= %s",
"s.created_at < %s::date + interval '1 day'",
"s.status != 'cancelled'"
]
params = [date_from, date_to]
if g.branch_id:
where_clauses.append("s.branch_id = %s")
params.append(g.branch_id)
where = " AND ".join(where_clauses)
cur.execute(f"""
SELECT s.id, s.customer_id, s.payment_method, s.total, s.status, s.created_at,
c.name as customer_name
FROM sales s
LEFT JOIN customers c ON s.customer_id = c.id
WHERE {where}
ORDER BY s.created_at DESC
LIMIT %s
""", params + [limit])
sales = []
sale_ids = []
for r in cur.fetchall():
sale_ids.append(r[0])
sales.append({
'id': r[0], 'customer_id': r[1], 'payment_method': r[2],
'total': float(r[3]) if r[3] else 0, 'status': r[4],
'created_at': str(r[5]), 'customer_name': r[6],
'items': []
})
if sale_ids:
cur.execute("""
SELECT sale_id, name, quantity
FROM sale_items
WHERE sale_id = ANY(%s)
ORDER BY sale_id, id
""", (sale_ids,))
for r in cur.fetchall():
for sale in sales:
if sale['id'] == r[0]:
sale['items'].append({'name': r[1], 'quantity': r[2]})
break
cur.close(); conn.close()
return jsonify({'data': sales})
@pos_bp.route('/historical-sales', methods=['GET']) @pos_bp.route('/historical-sales', methods=['GET'])
@require_auth('pos.view') @require_auth('pos.view')
def list_historical_sales(): def list_historical_sales():
@@ -476,16 +269,6 @@ def list_historical_sales():
where = " AND ".join(where_clauses) where = " AND ".join(where_clauses)
# Defensive: the historical_sales table is created on demand by imports.
# If it does not exist yet, return an empty list instead of a 500.
cur.execute("SELECT to_regclass('historical_sales')")
if cur.fetchone()[0] is None:
cur.close(); conn.close()
return jsonify({
'data': [],
'pagination': {'page': page, 'per_page': per_page, 'total': 0, 'total_pages': 0}
})
cur.execute(f"SELECT count(*) FROM historical_sales WHERE {where}", params) cur.execute(f"SELECT count(*) FROM historical_sales WHERE {where}", params)
total = cur.fetchone()[0] total = cur.fetchone()[0]
@@ -527,13 +310,9 @@ def list_historical_sales():
@pos_bp.route('/sales/<int:sale_id>', methods=['GET']) @pos_bp.route('/sales/<int:sale_id>', methods=['GET'])
@require_auth() @require_auth('pos.view')
def get_sale(sale_id): def get_sale(sale_id):
"""Get sale detail with items.""" """Get sale detail with items."""
# Allow POS users or accounting users to view receivable/sale detail.
if g.employee_role != 'owner' and not ({'pos.view', 'accounting.view'} & g.permissions):
return jsonify({'error': 'Missing permissions'}), 403
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
@@ -599,17 +378,12 @@ def get_sale(sale_id):
@pos_bp.route('/sales/<int:sale_id>/cancel', methods=['PUT']) @pos_bp.route('/sales/<int:sale_id>/cancel', methods=['PUT'])
@require_auth() @require_auth('pos.sell')
def api_cancel_sale(sale_id): def api_cancel_sale(sale_id):
"""Cancel a sale. Requires mandatory reason. """Cancel a sale. Requires mandatory reason.
Body: {reason: str} Body: {reason: str}
""" """
# Allow POS sellers or accounting staff to cancel tickets from the
# receivables / accounting view.
if g.employee_role != 'owner' and not ({'pos.sell', 'accounting.view'} & g.permissions):
return jsonify({'error': 'Missing permissions'}), 403
data = request.get_json() or {} data = request.get_json() or {}
reason = data.get('reason', '').strip() reason = data.get('reason', '').strip()
@@ -746,8 +520,6 @@ def create_quotation():
# Enrich items with inventory data (price, tax, etc.) # Enrich items with inventory data (price, tax, etc.)
try: try:
enriched = _enrich_items(cur, items, data.get('customer_id')) enriched = _enrich_items(cur, items, data.get('customer_id'))
_validate_stock_availability(conn, enriched, g.branch_id)
_validate_zero_price(conn, enriched)
except ValueError as e: except ValueError as e:
cur.close(); conn.close() cur.close(); conn.close()
return jsonify({'error': str(e)}), 400 return jsonify({'error': str(e)}), 400
@@ -910,23 +682,9 @@ def list_quotations():
@pos_bp.route('/quotations/<int:quot_id>', methods=['DELETE']) @pos_bp.route('/quotations/<int:quot_id>', methods=['DELETE'])
@require_auth('pos.sell') @require_auth('pos.sell')
def delete_quotation(quot_id): def delete_quotation(quot_id):
"""Delete a quotation, release its stock reservations and remove its items.""" """Delete a quotation and its items."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
# Release reserved stock before deleting items
from services.quote_reservation import (
release_quotation_reservation,
get_quotation_items_for_reservation
)
try:
reservation_items = get_quotation_items_for_reservation(conn, quot_id)
if reservation_items:
release_quotation_reservation(conn, quot_id, reservation_items, employee_id=g.employee_id)
except Exception:
# Continue with deletion even if release fails (e.g. no reservations)
pass
cur.execute("DELETE FROM quotation_items WHERE quotation_id = %s", (quot_id,)) cur.execute("DELETE FROM quotation_items WHERE quotation_id = %s", (quot_id,))
cur.execute("DELETE FROM quotations WHERE id = %s", (quot_id,)) cur.execute("DELETE FROM quotations WHERE id = %s", (quot_id,))
deleted = cur.rowcount deleted = cur.rowcount
@@ -1136,8 +894,6 @@ def update_quotation(quot_id):
try: try:
enriched = _enrich_items(cur, items, data.get('customer_id')) enriched = _enrich_items(cur, items, data.get('customer_id'))
_validate_stock_availability(conn, enriched, g.branch_id)
_validate_zero_price(conn, enriched)
except ValueError as e: except ValueError as e:
cur.close(); conn.close() cur.close(); conn.close()
return jsonify({'error': str(e)}), 400 return jsonify({'error': str(e)}), 400
@@ -1230,8 +986,6 @@ def patch_quotation(quot_id):
cur.close(); conn.close() cur.close(); conn.close()
return jsonify({'error': 'Quotation not found'}), 404 return jsonify({'error': 'Quotation not found'}), 404
old_status = row[1]
fields = [] fields = []
params = [] params = []
if 'customer_id' in data: if 'customer_id' in data:
@@ -1243,10 +997,9 @@ def patch_quotation(quot_id):
if 'valid_until' in data: if 'valid_until' in data:
fields.append('valid_until = %s') fields.append('valid_until = %s')
params.append(data['valid_until']) params.append(data['valid_until'])
new_status = data.get('status') if 'status' in data and data['status'] in ('active', 'cancelled', 'expired'):
if new_status and new_status in ('active', 'cancelled', 'expired'):
fields.append('status = %s') fields.append('status = %s')
params.append(new_status) params.append(data['status'])
if not fields: if not fields:
cur.close(); conn.close() cur.close(); conn.close()
@@ -1254,20 +1007,6 @@ def patch_quotation(quot_id):
params.append(quot_id) params.append(quot_id)
cur.execute(f"UPDATE quotations SET {', '.join(fields)} WHERE id = %s", params) cur.execute(f"UPDATE quotations SET {', '.join(fields)} WHERE id = %s", params)
# Release reservations when cancelling or expiring
if new_status in ('cancelled', 'expired') and old_status not in ('cancelled', 'expired', 'converted'):
from services.quote_reservation import (
release_quotation_reservation,
get_quotation_items_for_reservation
)
try:
reservation_items = get_quotation_items_for_reservation(conn, quot_id)
if reservation_items:
release_quotation_reservation(conn, quot_id, reservation_items, employee_id=g.employee_id)
except Exception:
pass
conn.commit() conn.commit()
cur.close(); conn.close() cur.close(); conn.close()
return jsonify({'message': 'Quotation updated'}) return jsonify({'message': 'Quotation updated'})
@@ -1617,15 +1356,12 @@ def convert_quotation(quot_id):
'register_id': data.get('register_id'), 'register_id': data.get('register_id'),
'amount_paid': data.get('amount_paid', 0), 'amount_paid': data.get('amount_paid', 0),
'payment_details': data.get('payment_details', []), 'payment_details': data.get('payment_details', []),
'reference': data.get('reference', ''),
'notes': f'Convertida de cotizacion #{quot_id}', 'notes': f'Convertida de cotizacion #{quot_id}',
'currency': quot_currency, 'currency': quot_currency,
'exchange_rate': quot_rate, 'exchange_rate': quot_rate,
} }
try: try:
_validate_stock_availability(conn, items, g.branch_id)
_validate_zero_price(conn, items)
sale = process_sale(conn, sale_data) sale = process_sale(conn, sale_data)
# Mark quotation as converted # Mark quotation as converted
@@ -1784,8 +1520,6 @@ def create_layaway():
# Enrich items with inventory data # Enrich items with inventory data
try: try:
enriched = _enrich_items(cur, items, customer_id) enriched = _enrich_items(cur, items, customer_id)
_validate_stock_availability(conn, enriched, g.branch_id)
_validate_zero_price(conn, enriched)
except ValueError as e: except ValueError as e:
cur.close(); conn.close() cur.close(); conn.close()
return jsonify({'error': str(e)}), 400 return jsonify({'error': str(e)}), 400
@@ -2177,14 +1911,10 @@ def complete_layaway(layaway_id):
# Create sale_items (no inventory deduction — already reserved) # Create sale_items (no inventory deduction — already reserved)
sale_items = [] sale_items = []
inv_ids = [item['inventory_id'] for item in totals_calc['items']]
cur.execute("""
SELECT id, part_number, name, cost FROM inventory WHERE id = ANY(%s)
""", (inv_ids,))
inv_map = {r[0]: (r[1], r[2], r[3]) for r in cur.fetchall()}
for item in totals_calc['items']: for item in totals_calc['items']:
inv = inv_map.get(item['inventory_id'], ('', '', 0)) cur.execute("SELECT part_number, name, cost FROM inventory WHERE id = %s",
(item['inventory_id'],))
inv = cur.fetchone()
cur.execute(""" cur.execute("""
INSERT INTO sale_items INSERT INTO sale_items
(sale_id, inventory_id, part_number, name, quantity, (sale_id, inventory_id, part_number, name, quantity,
@@ -2193,9 +1923,9 @@ def complete_layaway(layaway_id):
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
""", ( """, (
sale_id, item['inventory_id'], sale_id, item['inventory_id'],
inv[0] or '', inv[1] or '', inv[0] if inv else '', inv[1] if inv else '',
item['quantity'], item['unit_price'], item['quantity'], item['unit_price'],
float(inv[2]) if inv[2] else 0, float(inv[2]) if inv and inv[2] else 0,
item['discount_pct'], item['discount_amount'], item['discount_pct'], item['discount_amount'],
item['tax_rate'], item['tax_amount'], item['subtotal'] item['tax_rate'], item['tax_amount'], item['subtotal']
)) ))
@@ -2337,7 +2067,7 @@ def create_return():
try: try:
# Validate sale exists and is completed # Validate sale exists and is completed
cur.execute(""" cur.execute("""
SELECT id, customer_id, total, status, branch_id, sale_type SELECT id, customer_id, total, status, branch_id
FROM sales WHERE id = %s FROM sales WHERE id = %s
""", (sale_id,)) """, (sale_id,))
sale = cur.fetchone() sale = cur.fetchone()
@@ -2348,7 +2078,6 @@ def create_return():
sale_customer_id = sale[1] sale_customer_id = sale[1]
sale_branch_id = sale[4] or g.branch_id sale_branch_id = sale[4] or g.branch_id
sale_type = sale[5]
# Validate each return item against original sale items # Validate each return item against original sale items
total_refund = 0 total_refund = 0
@@ -2450,10 +2179,10 @@ def create_return():
new_status = 'returned' if returned_total >= sold_total else 'partially_returned' new_status = 'returned' if returned_total >= sold_total else 'partially_returned'
cur.execute("UPDATE sales SET status = %s WHERE id = %s", (new_status, sale_id)) cur.execute("UPDATE sales SET status = %s WHERE id = %s", (new_status, sale_id))
# Update customer credit if the original sale was on credit # Update customer credit if applicable
if sale_customer_id and sale_type == 'credit': if sale_customer_id:
cur.execute(""" cur.execute("""
UPDATE customers SET credit_balance = COALESCE(credit_balance, 0) - %s UPDATE customers SET credit_balance = COALESCE(credit_balance, 0) + %s
WHERE id = %s WHERE id = %s
""", (total_refund, sale_customer_id)) """, (total_refund, sale_customer_id))
@@ -2692,79 +2421,6 @@ def print_ticket(sale_id):
headers={'Content-Disposition': f'attachment; filename=ticket_{sale_id}.bin'}) headers={'Content-Disposition': f'attachment; filename=ticket_{sale_id}.bin'})
@pos_bp.route('/sales/<int:sale_id>/print-remission', methods=['POST'])
@require_auth()
def print_remission(sale_id):
"""Generate printable data for a counter remission note."""
from middleware import has_permission
if not (has_permission('pos.remission') or has_permission('pos.sell')):
return jsonify({'error': 'Missing permissions'}), 403
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT s.*, e.name as employee_name, c.name as customer_name, co.name as courier_name
FROM sales s
LEFT JOIN employees e ON s.employee_id = e.id
LEFT JOIN customers c ON s.customer_id = c.id
LEFT JOIN couriers co ON s.courier_id = co.id
WHERE s.id = %s
""", (sale_id,))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Sale not found'}), 404
cols = [desc[0] for desc in cur.description]
sale = dict(zip(cols, row))
for k in ('subtotal', 'discount_total', 'tax_total', 'total', 'amount_paid', 'change_given'):
if sale.get(k) is not None:
sale[k] = float(sale[k])
cur.execute("""
SELECT name, quantity, unit_price, subtotal
FROM sale_items WHERE sale_id = %s ORDER BY id
""", (sale_id,))
items = []
for r in cur.fetchall():
items.append({
'name': r[0], 'quantity': r[1],
'unit_price': float(r[2]) if r[2] else 0,
'subtotal': float(r[3]) if r[3] else 0,
})
business_info = {'name': 'NEXUS AUTOPARTS', 'rfc': '', 'address': ''}
try:
cur.execute("SELECT key, value FROM config WHERE key IN ('business_name','rfc','address')")
for rw in cur.fetchall():
if rw[0] == 'business_name':
business_info['name'] = rw[1]
else:
business_info[rw[0]] = rw[1]
except Exception:
pass
cur.close(); conn.close()
return jsonify({
'folio': f'NR-{sale["id"]}',
'date': str(sale.get('created_at', '')),
'employee': sale.get('employee_name', ''),
'customer': sale.get('customer_name', ''),
'courier': sale.get('courier_name', ''),
'items': items,
'subtotal': sale.get('subtotal', 0),
'discount_total': sale.get('discount_total', 0),
'tax_total': sale.get('tax_total', 0),
'total': sale.get('total', 0),
'status': sale.get('status', ''),
'business_name': business_info.get('name', ''),
'business_rfc': business_info.get('rfc', ''),
'business_address': business_info.get('address', ''),
})
# ─── Public Quote HTML Template ───────────────────────────────────────────── # ─── Public Quote HTML Template ─────────────────────────────────────────────
PUBLIC_QUOTE_TEMPLATE = """ PUBLIC_QUOTE_TEMPLATE = """

View File

@@ -3,21 +3,16 @@
Prefix: /pos/api/service-orders Prefix: /pos/api/service-orders
""" """
import json
from functools import wraps
from flask import Blueprint, g, jsonify, request from flask import Blueprint, g, jsonify, request
from middleware import require_auth from middleware import require_auth
from services.service_order_engine import ( from services.service_order_engine import (
add_item, add_item,
add_labor, add_labor,
assign_mechanic, assign_mechanic,
convert_to_remission,
convert_to_sale, convert_to_sale,
create_service_catalog_item, create_service_catalog_item,
create_service_order, create_service_order,
delete_service_catalog_item, delete_service_catalog_item,
delete_service_order,
get_kanban_summary, get_kanban_summary,
get_service_order, get_service_order,
list_service_catalog, list_service_catalog,
@@ -33,136 +28,17 @@ from services.service_order_engine import (
update_status, update_status,
) )
from tenant_db import get_tenant_conn from tenant_db import get_tenant_conn
from blueprints.config_bp import _get_workshop_permissions, _DEFAULT_WORKSHOP_PERMISSIONS
service_order_bp = Blueprint('service_orders', __name__, url_prefix='/pos/api/service-orders') service_order_bp = Blueprint('service_orders', __name__, url_prefix='/pos/api/service-orders')
def _tenant_allows_zero_price(conn):
"""Return True if the tenant allows selling items at $0. Defaults to True."""
cur = conn.cursor()
cur.execute("SELECT value FROM tenant_config WHERE key = 'allow_zero_price_sales'")
row = cur.fetchone()
cur.close()
return str(row[0]).lower() in ('true', '1', 'yes') if row else True
def _validate_zero_price(conn, items):
"""Raise ValueError if any item line would be <= $0 and the tenant forbids it."""
if _tenant_allows_zero_price(conn):
return
for item in items:
unit_price = float(item.get('unit_price', 0) or 0)
quantity = float(item.get('quantity', 1) or 1)
discount_pct = float(item.get('discount_pct', 0) or 0)
line_total = unit_price * quantity * (1 - discount_pct / 100)
if line_total <= 0:
name = item.get('name') or item.get('part_number') or item.get('inventory_id')
raise ValueError(f"No está permitido vender artículos en $0 ({name})")
# Roles allowed to access the workshop module at all.
_WORKSHOP_VIEW_ROLES = {'owner', 'admin', 'counter', 'cashier', 'workshop', 'mechanic'}
def _can_view_workshop():
return g.employee_role in _WORKSHOP_VIEW_ROLES or 'workshop.view' in g.permissions
def _get_ws_perms():
"""Return effective workshop permissions for the current user."""
conn = get_tenant_conn(g.tenant_id)
try:
return _get_workshop_permissions(conn, g.employee_role)
finally:
conn.close()
def _visible_statuses():
if g.employee_role in ('owner', 'admin'):
return list(_DEFAULT_WORKSHOP_PERMISSIONS['admin']['statuses'])
return _get_ws_perms().get('statuses', [])
def _can_do_action(action):
if g.employee_role in ('owner', 'admin'):
return True
return action in _get_ws_perms().get('actions', [])
def _redact_order_for_restricted(order):
"""Remove customer/commercial data when the role cannot view sensitive data."""
if g.employee_role in ('owner', 'admin') or _can_do_action('view_customer_data'):
return order
sensitive = {
'customer_id', 'customer_name', 'customer_phone', 'customer_address',
'workshop_name', 'vehicle_description', 'vehicle_plate', 'vehicle_make',
'vehicle_model', 'delivery_method', 'courier_id', 'courier_name',
'requires_invoice', 'employee_id', 'employee_name', 'estimated_cost',
'final_cost', 'total_parts', 'total_labor', 'total',
}
def redact_value(v):
if isinstance(v, str):
return ''
if isinstance(v, (int, float)):
return 0
return None
redacted = {}
for k, v in order.items():
if k in sensitive:
redacted[k] = redact_value(v)
elif k == 'items' and isinstance(v, list):
redacted[k] = [_redact_item_for_restricted(it) for it in v]
else:
redacted[k] = v
return redacted
def _redact_item_for_restricted(item):
"""Hide sensitive item rows for restricted viewers."""
item = dict(item)
for k in ('mechanic_id', 'unit_cost', 'unit_price'):
if k in item:
item[k] = None if not isinstance(item[k], (int, float)) else 0
return item
def require_workshop_view(f):
@wraps(f)
@require_auth()
def decorated(*args, **kwargs):
if not _can_view_workshop():
return jsonify({'error': 'Missing permissions: workshop.view'}), 403
return f(*args, **kwargs)
return decorated
def require_workshop_action(action):
def decorator(f):
@wraps(f)
@require_auth()
def decorated(*args, **kwargs):
if not _can_view_workshop():
return jsonify({'error': 'Missing permissions: workshop.view'}), 403
if not _can_do_action(action):
return jsonify({'error': f'Missing permissions: workshop.{action}'}), 403
return f(*args, **kwargs)
return decorated
return decorator
@service_order_bp.route('', methods=['GET']) @service_order_bp.route('', methods=['GET'])
@require_workshop_view @require_auth()
def list_orders(): def list_orders():
status = request.args.get('status') status = request.args.get('status')
priority = request.args.get('priority') priority = request.args.get('priority')
customer_id = request.args.get('customer_id', type=int) customer_id = request.args.get('customer_id', type=int)
employee_id = request.args.get('employee_id', type=int) employee_id = request.args.get('employee_id', type=int)
delivery_method = request.args.get('delivery_method')
is_direct = request.args.get('is_direct', type=lambda v: v.lower() == 'true') if 'is_direct' in request.args else None
q = request.args.get('q')
page = int(request.args.get('page', 1)) page = int(request.args.get('page', 1))
per_page = min(int(request.args.get('per_page', 50)), 200) per_page = min(int(request.args.get('per_page', 50)), 200)
@@ -171,65 +47,15 @@ def list_orders():
result = list_service_orders( result = list_service_orders(
conn, status=status, branch_id=g.branch_id, conn, status=status, branch_id=g.branch_id,
customer_id=customer_id, priority=priority, customer_id=customer_id, priority=priority,
employee_id=employee_id, delivery_method=delivery_method, employee_id=employee_id, page=page, per_page=per_page
is_direct=is_direct, q=q, page=page, per_page=per_page
) )
if g.employee_role not in ('owner', 'admin'):
visible = _visible_statuses()
result['data'] = [
_redact_order_for_restricted(o) for o in result.get('data', [])
if o.get('status') in visible
]
return jsonify(result) return jsonify(result)
finally: finally:
conn.close() conn.close()
@service_order_bp.route('/from-pos', methods=['POST'])
@require_auth('pos.sell')
def create_order_from_pos():
"""Create a service order directly from the POS cart (quotation)."""
data = request.get_json() or {}
items = data.get('items', [])
estimated_cost = sum(
(it.get('unit_price') or 0) * (it.get('quantity') or 1)
for it in items
)
conn = get_tenant_conn(g.tenant_id)
try:
_validate_zero_price(conn, items)
result = create_service_order(conn, {
'tenant_id': g.tenant_id,
'branch_id': data.get('branch_id', g.branch_id),
'customer_id': data.get('customer_id'),
'vehicle_id': data.get('vehicle_id'),
'priority': data.get('priority', 'normal'),
'reception_notes': data.get('reception_notes'),
'estimated_cost': estimated_cost or None,
'estimated_completion': data.get('estimated_completion'),
'employee_id': data.get('employee_id'),
'mileage_in': data.get('mileage_in'),
'created_by': getattr(g, 'employee_id', None),
'delivery_method': data.get('delivery_method'),
'courier_id': data.get('courier_id'),
'is_direct': data.get('is_direct', False),
'requires_invoice': data.get('requires_invoice', False),
'workshop_name': data.get('workshop_name'),
'customer_address': data.get('customer_address'),
'customer_phone': data.get('customer_phone'),
'vehicle_description': data.get('vehicle_description'),
})
so_id = result['service_order_id']
for item in items:
add_item(conn, so_id, item)
order = get_service_order(conn, so_id)
return jsonify(order), 201
finally:
conn.close()
@service_order_bp.route('', methods=['POST']) @service_order_bp.route('', methods=['POST'])
@require_workshop_action('create_order') @require_auth()
def create_order(): def create_order():
data = request.get_json() or {} data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
@@ -247,14 +73,6 @@ def create_order():
'mileage_in': data.get('mileage_in'), 'mileage_in': data.get('mileage_in'),
'fuel_level': data.get('fuel_level'), 'fuel_level': data.get('fuel_level'),
'created_by': getattr(g, 'employee_id', None), 'created_by': getattr(g, 'employee_id', None),
'delivery_method': data.get('delivery_method'),
'courier_id': data.get('courier_id'),
'is_direct': data.get('is_direct', False),
'requires_invoice': data.get('requires_invoice', False),
'workshop_name': data.get('workshop_name'),
'customer_address': data.get('customer_address'),
'customer_phone': data.get('customer_phone'),
'vehicle_description': data.get('vehicle_description'),
}) })
return jsonify(result), 201 return jsonify(result), 201
finally: finally:
@@ -262,29 +80,22 @@ def create_order():
@service_order_bp.route('/<int:so_id>', methods=['GET']) @service_order_bp.route('/<int:so_id>', methods=['GET'])
@require_workshop_view @require_auth()
def get_order(so_id): def get_order(so_id):
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
try: try:
order = get_service_order(conn, so_id) order = get_service_order(conn, so_id)
if not order: if not order:
return jsonify({'error': 'Service order not found'}), 404 return jsonify({'error': 'Service order not found'}), 404
if order.get('status') not in _visible_statuses(): return jsonify(order)
return jsonify({'error': 'No tienes acceso a esta orden'}), 403
return jsonify(_redact_order_for_restricted(order))
finally: finally:
conn.close() conn.close()
@service_order_bp.route('/<int:so_id>', methods=['PUT']) @service_order_bp.route('/<int:so_id>', methods=['PUT'])
@require_workshop_action('edit_order') @require_auth()
def update_order(so_id): def update_order(so_id):
data = request.get_json() or {} data = request.get_json() or {}
# Mechanics can only update diagnosis_notes, repair_notes and the free-text mechanic_name.
if g.employee_role == 'mechanic':
allowed = {'diagnosis_notes', 'repair_notes', 'mechanic_name'}
if not data or any(k not in allowed for k in data.keys()):
return jsonify({'error': 'Solo puedes editar notas de diagnostico, reparacion y asignar mecanico'}), 403
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
try: try:
ok = update_service_order(conn, so_id, data) ok = update_service_order(conn, so_id, data)
@@ -295,32 +106,13 @@ def update_order(so_id):
conn.close() conn.close()
@service_order_bp.route('/<int:so_id>', methods=['DELETE'])
@require_workshop_action('delete_order')
def delete_order(so_id):
"""Soft-delete a service order. Restricted to owner/admin."""
if g.employee_role not in ('owner', 'admin'):
return jsonify({'error': 'Solo administradores pueden eliminar órdenes'}), 403
conn = get_tenant_conn(g.tenant_id)
try:
delete_service_order(conn, so_id)
return jsonify({'message': 'Orden eliminada'})
except ValueError as e:
return jsonify({'error': str(e)}), 404
finally:
conn.close()
@service_order_bp.route('/<int:so_id>/status', methods=['PUT']) @service_order_bp.route('/<int:so_id>/status', methods=['PUT'])
@require_workshop_action('change_status') @require_auth()
def change_status(so_id): def change_status(so_id):
data = request.get_json() or {} data = request.get_json() or {}
new_status = data.get('status') new_status = data.get('status')
if not new_status: if not new_status:
return jsonify({'error': 'status is required'}), 400 return jsonify({'error': 'status is required'}), 400
if new_status not in _visible_statuses():
return jsonify({'error': 'No puedes cambiar a este estatus'}), 403
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
try: try:
result = update_status( result = update_status(
@@ -338,7 +130,7 @@ def change_status(so_id):
# ─── Items (Parts) ───────────────────────────── # ─── Items (Parts) ─────────────────────────────
@service_order_bp.route('/<int:so_id>/items', methods=['POST']) @service_order_bp.route('/<int:so_id>/items', methods=['POST'])
@require_workshop_action('add_items') @require_auth()
def add_order_item(so_id): def add_order_item(so_id):
data = request.get_json() or {} data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
@@ -350,7 +142,7 @@ def add_order_item(so_id):
@service_order_bp.route('/items/<int:item_id>', methods=['PUT']) @service_order_bp.route('/items/<int:item_id>', methods=['PUT'])
@require_workshop_action('add_items') @require_auth()
def update_order_item(item_id): def update_order_item(item_id):
data = request.get_json() or {} data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
@@ -364,7 +156,7 @@ def update_order_item(item_id):
@service_order_bp.route('/items/<int:item_id>', methods=['DELETE']) @service_order_bp.route('/items/<int:item_id>', methods=['DELETE'])
@require_workshop_action('add_items') @require_auth()
def delete_order_item(item_id): def delete_order_item(item_id):
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
try: try:
@@ -377,7 +169,7 @@ def delete_order_item(item_id):
# ─── Labor ───────────────────────────── # ─── Labor ─────────────────────────────
@service_order_bp.route('/<int:so_id>/labor', methods=['POST']) @service_order_bp.route('/<int:so_id>/labor', methods=['POST'])
@require_workshop_action('add_labor') @require_auth()
def add_order_labor(so_id): def add_order_labor(so_id):
data = request.get_json() or {} data = request.get_json() or {}
if not data.get('description'): if not data.get('description'):
@@ -391,7 +183,7 @@ def add_order_labor(so_id):
@service_order_bp.route('/labor/<int:labor_id>', methods=['PUT']) @service_order_bp.route('/labor/<int:labor_id>', methods=['PUT'])
@require_workshop_action('add_labor') @require_auth()
def update_order_labor(labor_id): def update_order_labor(labor_id):
data = request.get_json() or {} data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
@@ -405,7 +197,7 @@ def update_order_labor(labor_id):
@service_order_bp.route('/labor/<int:labor_id>', methods=['DELETE']) @service_order_bp.route('/labor/<int:labor_id>', methods=['DELETE'])
@require_workshop_action('add_labor') @require_auth()
def delete_order_labor(labor_id): def delete_order_labor(labor_id):
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
try: try:
@@ -418,7 +210,7 @@ def delete_order_labor(labor_id):
# ─── Kanban Summary ───────────────────────────── # ─── Kanban Summary ─────────────────────────────
@service_order_bp.route('/kanban/summary', methods=['GET']) @service_order_bp.route('/kanban/summary', methods=['GET'])
@require_workshop_view @require_auth()
def kanban_summary(): def kanban_summary():
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
try: try:
@@ -432,7 +224,7 @@ def kanban_summary():
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/reserve', methods=['POST']) @service_order_bp.route('/<int:so_id>/items/<int:item_id>/reserve', methods=['POST'])
@require_workshop_action('add_items') @require_auth()
def reserve_order_item(so_id, item_id): def reserve_order_item(so_id, item_id):
"""Reserve inventory for a service order item.""" """Reserve inventory for a service order item."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
@@ -446,7 +238,7 @@ def reserve_order_item(so_id, item_id):
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/release', methods=['POST']) @service_order_bp.route('/<int:so_id>/items/<int:item_id>/release', methods=['POST'])
@require_workshop_action('add_items') @require_auth()
def release_order_item(so_id, item_id): def release_order_item(so_id, item_id):
"""Release a previous inventory reservation.""" """Release a previous inventory reservation."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
@@ -464,7 +256,6 @@ def release_order_item(so_id, item_id):
@service_order_bp.route('/<int:so_id>/convert-to-sale', methods=['POST']) @service_order_bp.route('/<int:so_id>/convert-to-sale', methods=['POST'])
@require_auth('pos.sell') @require_auth('pos.sell')
@require_workshop_action('convert_to_sale')
def convert_order_to_sale(so_id): def convert_order_to_sale(so_id):
"""Convert a service order into a POS sale. """Convert a service order into a POS sale.
@@ -499,43 +290,11 @@ def convert_order_to_sale(so_id):
conn.close() conn.close()
# ─── Convert to remission note ────────────────────
@service_order_bp.route('/<int:so_id>/convert-to-remission', methods=['POST'])
@require_auth('pos.remission')
@require_workshop_action('convert_to_remission')
def convert_order_to_remission(so_id):
"""Convert a service order into a counter remission note (pending payment).
Body: {
register_id: int (optional),
notes: str (optional)
}
"""
data = request.get_json() or {}
sale_payload = {
'register_id': data.get('register_id'),
'notes': data.get('notes'),
}
conn = get_tenant_conn(g.tenant_id)
try:
result = convert_to_remission(
conn, so_id, sale_payload, employee_id=g.employee_id
)
return jsonify(result), 201
except ValueError as e:
return jsonify({'error': str(e)}), 400
finally:
conn.close()
# ─── Mechanic assignment ────────────────────────── # ─── Mechanic assignment ──────────────────────────
@service_order_bp.route('/<int:so_id>/assign-mechanic', methods=['PUT']) @service_order_bp.route('/<int:so_id>/assign-mechanic', methods=['PUT'])
@require_workshop_action('assign_mechanic') @require_auth()
def assign_mechanic_endpoint(so_id): def assign_mechanic_endpoint(so_id):
"""Assign a mechanic/technician to a service order.""" """Assign a mechanic/technician to a service order."""
data = request.get_json() or {} data = request.get_json() or {}
@@ -553,144 +312,11 @@ def assign_mechanic_endpoint(so_id):
conn.close() conn.close()
@service_order_bp.route('/customers', methods=['POST'])
@require_workshop_action('edit_order')
def create_customer_for_workshop():
"""Create a customer directly from the workshop flow."""
data = request.get_json() or {}
if not data.get('name'):
return jsonify({'error': 'El nombre es obligatorio'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
try:
cur.execute("""
INSERT INTO customers
(branch_id, name, rfc, razon_social, regimen_fiscal, uso_cfdi,
cp, email, phone, address, price_tier, credit_limit, max_discount_pct, vehicle_info)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING id
""", (
data.get('branch_id', g.branch_id), data['name'], data.get('rfc'),
data.get('razon_social'), data.get('regimen_fiscal'), data.get('uso_cfdi', 'G03'),
data.get('cp'), data.get('email'), data.get('phone'), data.get('address'),
data.get('price_tier', 1), data.get('credit_limit', 0), data.get('max_discount_pct', 0),
json.dumps(data['vehicle_info']) if data.get('vehicle_info') else None
))
customer_id = cur.fetchone()[0]
conn.commit()
cur.close(); conn.close()
return jsonify({'id': customer_id, 'message': 'Cliente creado'}), 201
except Exception as e:
conn.rollback()
cur.close(); conn.close()
return jsonify({'error': str(e)}), 500
@service_order_bp.route('/vehicles', methods=['POST'])
@require_workshop_action('edit_order')
def create_vehicle_for_workshop():
"""Create a fleet vehicle directly from the workshop flow.
Vehicles must be assigned to a customer. Brand and model are required;
plate is optional and VIN is not used.
"""
data = request.get_json() or {}
if not data.get('make') or not data.get('model'):
return jsonify({'error': 'Marca y modelo son obligatorios'}), 400
if not data.get('customer_id'):
return jsonify({'error': 'El vehiculo debe estar asignado a un cliente'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
try:
cur.execute("SELECT name FROM customers WHERE id = %s", (data['customer_id'],))
cust = cur.fetchone()
if not cust:
cur.close(); conn.close()
return jsonify({'error': 'Cliente no encontrado'}), 404
cur.execute("""
INSERT INTO fleet_vehicles
(branch_id, customer_id, plate, make, model, year,
current_mileage, fuel_type, color, owner_name, notes)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING id
""", (
data.get('branch_id', g.branch_id), data['customer_id'], data.get('plate'),
data.get('make'), data.get('model'), data.get('year'),
data.get('current_mileage', 0), data.get('fuel_type', 'gasolina'),
data.get('color'), cust[0], data.get('notes'),
))
vehicle_id = cur.fetchone()[0]
conn.commit()
cur.close(); conn.close()
return jsonify({'id': vehicle_id, 'message': 'Vehiculo creado'}), 201
except Exception as e:
conn.rollback()
cur.close(); conn.close()
return jsonify({'error': str(e)}), 500
@service_order_bp.route('/inventory-search', methods=['GET'])
@require_workshop_view
def inventory_search():
"""Search active inventory for attaching parts to a service order.
Does not require inventory.view so workshop users can pick parts.
"""
q = request.args.get('q', '').strip()
if not q:
return jsonify({'data': []})
branch_id = request.args.get('branch_id', g.branch_id)
conn = get_tenant_conn(g.tenant_id)
try:
cur = conn.cursor()
like = f'%{q}%'
if branch_id:
cur.execute("""
SELECT i.id, i.part_number, i.name, i.brand, i.unit,
COALESCE((SELECT stock FROM inventory_stock
WHERE inventory_id = i.id AND branch_id = %s), 0) AS stock,
i.cost, i.price_1
FROM inventory i
WHERE i.is_active = true
AND (i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s)
ORDER BY i.name
LIMIT 20
""", (branch_id, like, like, like))
else:
cur.execute("""
SELECT i.id, i.part_number, i.name, i.brand, i.unit,
COALESCE((SELECT stock FROM inventory_stock_summary
WHERE inventory_id = i.id), 0) AS stock,
i.cost, i.price_1
FROM inventory i
WHERE i.is_active = true
AND (i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s)
ORDER BY i.name
LIMIT 20
""", (like, like, like))
items = []
for r in cur.fetchall():
items.append({
'id': r[0], 'part_number': r[1], 'name': r[2], 'brand': r[3], 'unit': r[4],
'stock': float(r[5]) if r[5] else 0,
'cost': float(r[6]) if r[6] else 0,
'price_1': float(r[7]) if r[7] else 0,
})
cur.close()
return jsonify({'data': items})
finally:
conn.close()
# ─── Service catalog (reusable labor) ───────────── # ─── Service catalog (reusable labor) ─────────────
@service_order_bp.route('/service-catalog', methods=['GET']) @service_order_bp.route('/service-catalog', methods=['GET'])
@require_workshop_view @require_auth()
def list_catalog(): def list_catalog():
"""List reusable labor/service concepts.""" """List reusable labor/service concepts."""
active_only = request.args.get('active_only', 'true').lower() != 'false' active_only = request.args.get('active_only', 'true').lower() != 'false'
@@ -703,7 +329,7 @@ def list_catalog():
@service_order_bp.route('/service-catalog', methods=['POST']) @service_order_bp.route('/service-catalog', methods=['POST'])
@require_workshop_action('edit_order') @require_auth()
def create_catalog_item(): def create_catalog_item():
"""Create a reusable labor concept.""" """Create a reusable labor concept."""
data = request.get_json() or {} data = request.get_json() or {}
@@ -719,7 +345,7 @@ def create_catalog_item():
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['PUT']) @service_order_bp.route('/service-catalog/<int:item_id>', methods=['PUT'])
@require_workshop_action('edit_order') @require_auth()
def update_catalog_item(item_id): def update_catalog_item(item_id):
"""Update a reusable labor concept.""" """Update a reusable labor concept."""
data = request.get_json() or {} data = request.get_json() or {}
@@ -734,7 +360,7 @@ def update_catalog_item(item_id):
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['DELETE']) @service_order_bp.route('/service-catalog/<int:item_id>', methods=['DELETE'])
@require_workshop_action('edit_order') @require_auth()
def delete_catalog_item(item_id): def delete_catalog_item(item_id):
"""Soft-delete a reusable labor concept.""" """Soft-delete a reusable labor concept."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
@@ -749,7 +375,7 @@ def delete_catalog_item(item_id):
@service_order_bp.route('/<int:so_id>/print', methods=['POST']) @service_order_bp.route('/<int:so_id>/print', methods=['POST'])
@require_workshop_action('edit_order') @require_auth()
def print_service_order_ticket(so_id): def print_service_order_ticket(so_id):
"""Generate a printable ticket for a service order. """Generate a printable ticket for a service order.

View File

@@ -28,7 +28,7 @@ def enqueue_report():
@tasks_bp.route('/<task_id>/status', methods=['GET']) @tasks_bp.route('/<task_id>/status', methods=['GET'])
@require_auth() @require_auth
def task_status(task_id): def task_status(task_id):
"""Get status of a background task.""" """Get status of a background task."""
from celery_app import celery from celery_app import celery

View File

@@ -43,8 +43,8 @@ def require_auth(*required_permissions):
# Check permissions # Check permissions
if required_permissions: if required_permissions:
missing = set(required_permissions) - g.permissions missing = set(required_permissions) - g.permissions
# owner/admin roles bypass all permission checks # owner role bypasses all permission checks
if g.employee_role not in ('owner', 'admin') and missing: if g.employee_role != 'owner' and missing:
return jsonify({'error': f'Missing permissions: {", ".join(missing)}'}), 403 return jsonify({'error': f'Missing permissions: {", ".join(missing)}'}), 403
return f(*args, **kwargs) return f(*args, **kwargs)
@@ -54,4 +54,4 @@ def require_auth(*required_permissions):
def has_permission(permission): def has_permission(permission):
"""Check if current user has a specific permission. Use inside a route.""" """Check if current user has a specific permission. Use inside a route."""
return g.employee_role in ('owner', 'admin') or permission in g.permissions return g.employee_role == 'owner' or permission in g.permissions

View File

@@ -52,24 +52,6 @@ MIGRATIONS = {
"v4.3": "v4.3_facturapi.sql", "v4.3": "v4.3_facturapi.sql",
"v4.4": "v4.4_workshop.sql", "v4.4": "v4.4_workshop.sql",
"v4.5": "v4.5_customer_max_discount.sql", "v4.5": "v4.5_customer_max_discount.sql",
"v4.6": "v4.6_inventory_support.sql",
"v4.7": "v4.7_workshop_business.sql",
"v4.8": "v4.8_workshop_permissions.sql",
"v4.9": "v4.9_workshop_customers_view.sql",
"v4.10": "v4.10_fleet_permissions.sql",
"v4.11": "v4.11_clean_workshop_mechanic_permissions.sql",
"v4.12": "v4.12_service_order_soft_delete.sql",
"v4.13": "v4.13_fleet_vehicle_customer.sql",
"v4.14": "v4.14_service_order_delivery_cleanup.sql",
"v4.15": "v4.15_counter_remission.sql",
"v4.16": "v4.16_remission_courier.sql",
"v4.17": "v4.17_service_order_invoice.sql",
"v4.18": "v4.18_rached_workshop.sql",
"v4.19": "v4.19_counter_inventory_create.sql",
"v4.20": "v4.20_cashier_sell_permissions.sql",
"v4.21": "v4.21_cashier_workshop_permissions.sql",
"v4.22": "v4.22_cashier_invoicing_permissions.sql",
"v4.23": "v4.23_service_order_mechanic_name.sql",
} }
@@ -125,10 +107,7 @@ def apply_migration(db_name, version):
def run_migrations(): def run_migrations():
"""Apply pending migrations to all tenants.""" """Apply pending migrations to all tenants."""
tenants = get_all_tenants() tenants = get_all_tenants()
def _version_key(v): sorted_versions = sorted(MIGRATIONS.keys())
return tuple(int(x) for x in v.lstrip('v').split('.'))
sorted_versions = sorted(MIGRATIONS.keys(), key=_version_key)
print(f"Found {len(tenants)} active tenants") print(f"Found {len(tenants)} active tenants")
print(f"Available migrations: {sorted_versions}") print(f"Available migrations: {sorted_versions}")
@@ -136,9 +115,8 @@ def run_migrations():
for tenant_id, db_name, name, current_version in tenants: for tenant_id, db_name, name, current_version in tenants:
print(f"\n[{name}] (db={db_name}, current={current_version})") print(f"\n[{name}] (db={db_name}, current={current_version})")
current_key = _version_key(current_version)
for version in sorted_versions: for version in sorted_versions:
if _version_key(version) <= current_key: if version <= current_version:
continue continue
print(f" Applying {version}...", end=" ") print(f" Applying {version}...", end=" ")

View File

@@ -1,20 +0,0 @@
-- v4.10: add fleet RBAC permissions for existing employees.
-- Owner and admin get full fleet access; accountant and workshop get read access.
INSERT INTO employee_permissions (employee_id, permission)
SELECT e.id, p.perm
FROM employees e
CROSS JOIN (VALUES
('fleet.view'),
('fleet.create'),
('fleet.edit'),
('fleet.delete')
) AS p(perm)
WHERE e.role IN ('owner', 'admin')
ON CONFLICT (employee_id, permission) DO NOTHING;
INSERT INTO employee_permissions (employee_id, permission)
SELECT e.id, 'fleet.view'
FROM employees e
WHERE e.role IN ('accountant', 'workshop')
ON CONFLICT (employee_id, permission) DO NOTHING;

View File

@@ -1,24 +0,0 @@
-- v4.11_clean_workshop_mechanic_permissions.sql
-- Restringe los permisos de los empleados con rol 'workshop' y 'mechanic'
-- para que solo tengan acceso a la seccion de Taller y no vean precios.
-- Ejecutar en cada base de datos de tenant.
-- 1. Eliminar cualquier permiso que no sea de taller para estos roles
DELETE FROM employee_permissions
WHERE employee_id IN (
SELECT id FROM employees WHERE role IN ('workshop', 'mechanic')
)
AND permission NOT IN ('workshop.view', 'workshop.edit', 'workshop.add_items');
-- 2. Asegurar que tengan los permisos base de taller
INSERT INTO employee_permissions (employee_id, permission)
SELECT e.id, p.permission
FROM employees e
CROSS JOIN (
VALUES
('workshop.view'),
('workshop.edit'),
('workshop.add_items')
) AS p(permission)
WHERE e.role IN ('workshop', 'mechanic')
ON CONFLICT (employee_id, permission) DO NOTHING;

View File

@@ -1,11 +0,0 @@
-- v4.12_service_order_soft_delete.sql
-- Soft-delete support for service orders.
ALTER TABLE service_orders
ADD COLUMN IF NOT EXISTS is_deleted BOOLEAN NOT NULL DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_service_orders_is_deleted
ON service_orders (is_deleted);
-- Exclude deleted orders from common queries by default via partial index helpers
-- (application filters explicitly).

View File

@@ -1,20 +0,0 @@
-- v4.13_fleet_vehicle_customer.sql
-- Link fleet vehicles to a customer.
ALTER TABLE fleet_vehicles
ADD COLUMN IF NOT EXISTS customer_id INTEGER;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fleet_vehicles_customer_id_fkey'
) THEN
ALTER TABLE fleet_vehicles
ADD CONSTRAINT fleet_vehicles_customer_id_fkey
FOREIGN KEY (customer_id) REFERENCES customers(id);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_fleet_vehicles_customer_id
ON fleet_vehicles (customer_id);

View File

@@ -1,10 +0,0 @@
-- Normalize service order delivery options to only "pickup" (mostrador) and "delivery" (a domicilio).
-- Convert legacy "courier" records to pickup and clear courier_id when delivery is not "delivery".
UPDATE service_orders
SET delivery_method = 'pickup'
WHERE delivery_method = 'courier';
UPDATE service_orders
SET courier_id = NULL
WHERE delivery_method IS NULL OR delivery_method != 'delivery';

View File

@@ -1,18 +0,0 @@
-- v4.15: Counter remission note support
-- Ensures employees with role 'counter' have the base permissions needed to
-- generate remission notes from the POS.
INSERT INTO employee_permissions (employee_id, permission)
SELECT e.id, p.permission
FROM employees e
CROSS JOIN (
VALUES
('pos.remission'),
('pos.view'),
('catalog.view'),
('inventory.view'),
('customers.view'),
('customers.create')
) AS p(permission)
WHERE e.role = 'counter'
ON CONFLICT (employee_id, permission) DO NOTHING;

View File

@@ -1,18 +0,0 @@
-- v4.16: Add courier assignment to counter remission notes.
ALTER TABLE sales
ADD COLUMN IF NOT EXISTS courier_id INTEGER;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'sales_courier_id_fkey'
) THEN
ALTER TABLE sales
ADD CONSTRAINT sales_courier_id_fkey
FOREIGN KEY (courier_id) REFERENCES couriers(id);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_sales_courier_id ON sales(courier_id);

View File

@@ -1,6 +0,0 @@
-- Service order invoice requirement flag
ALTER TABLE service_orders
ADD COLUMN IF NOT EXISTS requires_invoice BOOLEAN DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_service_orders_requires_invoice
ON service_orders(requires_invoice);

View File

@@ -1,32 +0,0 @@
-- v4.18 Rached workshop fields
-- Extends service orders and items to match the Rached legacy workshop flow.
-- Applied to all tenants.
-- ═════════════════════════════════════════════════════════════════════════════
-- 1. SERVICE_ORDERS: capture free-text customer/vehicle/workshop data
-- ═════════════════════════════════════════════════════════════════════════════
ALTER TABLE service_orders
ADD COLUMN IF NOT EXISTS workshop_name VARCHAR(200),
ADD COLUMN IF NOT EXISTS customer_address TEXT,
ADD COLUMN IF NOT EXISTS customer_phone VARCHAR(50),
ADD COLUMN IF NOT EXISTS vehicle_description VARCHAR(300);
COMMENT ON COLUMN service_orders.workshop_name IS 'Free-text workshop/customer alias (Rached "Taller")';
COMMENT ON COLUMN service_orders.customer_address IS 'Address captured or imported for the service order';
COMMENT ON COLUMN service_orders.customer_phone IS 'Phone captured or imported for the service order';
COMMENT ON COLUMN service_orders.vehicle_description IS 'Free-text vehicle description (alternative to fleet_vehicles)';
CREATE INDEX IF NOT EXISTS idx_service_orders_workshop_name ON service_orders(workshop_name);
CREATE INDEX IF NOT EXISTS idx_service_orders_vehicle_description ON service_orders(vehicle_description);
-- ═════════════════════════════════════════════════════════════════════════════
-- 2. SERVICE_ORDER_ITEMS: mechanic per item + explicit observations
-- ═════════════════════════════════════════════════════════════════════════════
ALTER TABLE service_order_items
ADD COLUMN IF NOT EXISTS mechanic_id INTEGER REFERENCES employees(id),
ADD COLUMN IF NOT EXISTS observations TEXT;
COMMENT ON COLUMN service_order_items.mechanic_id IS 'Mechanic assigned to this specific line item';
COMMENT ON COLUMN service_order_items.observations IS 'Line-item observations (Rached detail notes)';
CREATE INDEX IF NOT EXISTS idx_service_order_items_mechanic_id ON service_order_items(mechanic_id);

View File

@@ -1,11 +0,0 @@
-- v4.19 — Grant inventory.create permission to existing counter employees
-- so they can create items and record purchase entries.
INSERT INTO employee_permissions (employee_id, permission)
SELECT e.id, 'inventory.create'
FROM employees e
WHERE e.role = 'counter'
AND e.is_active = true
AND NOT EXISTS (
SELECT 1 FROM employee_permissions ep
WHERE ep.employee_id = e.id AND ep.permission = 'inventory.create'
);

View File

@@ -1,18 +0,0 @@
-- v4.20 — Ensure existing cashier employees can sell and view quotations.
-- The cashier default set already includes these permissions, but employees
-- created before the fix may be missing them.
INSERT INTO employee_permissions (employee_id, permission)
SELECT e.id, p.perm
FROM employees e
CROSS JOIN (VALUES
('pos.sell'),
('pos.discount'),
('pos.cancel'),
('pos.view')
) AS p(perm)
WHERE e.role = 'cashier'
AND e.is_active = true
AND NOT EXISTS (
SELECT 1 FROM employee_permissions ep
WHERE ep.employee_id = e.id AND ep.permission = p.perm
);

View File

@@ -1,15 +0,0 @@
-- v4.21 — Allow existing cashier employees to create and edit service orders.
INSERT INTO employee_permissions (employee_id, permission)
SELECT e.id, p.perm
FROM employees e
CROSS JOIN (VALUES
('workshop.view'),
('workshop.edit'),
('workshop.add_items')
) AS p(perm)
WHERE e.role = 'cashier'
AND e.is_active = true
AND NOT EXISTS (
SELECT 1 FROM employee_permissions ep
WHERE ep.employee_id = e.id AND ep.permission = p.perm
);

View File

@@ -1,15 +0,0 @@
-- v4.22 -- Enable invoicing for existing cashier employees.
INSERT INTO employee_permissions (employee_id, permission)
SELECT e.id, p.perm
FROM employees e
CROSS JOIN (VALUES
('invoicing.view'),
('invoicing.create'),
('invoicing.cancel')
) AS p(perm)
WHERE e.role = 'cashier'
AND e.is_active = true
AND NOT EXISTS (
SELECT 1 FROM employee_permissions ep
WHERE ep.employee_id = e.id AND ep.permission = p.perm
);

View File

@@ -1,2 +0,0 @@
-- v4.23 -- Add free-text mechanic name for service orders.
ALTER TABLE service_orders ADD COLUMN IF NOT EXISTS mechanic_name TEXT;

View File

@@ -1,20 +0,0 @@
-- Tenant DB schema v4.6 — support tables for inventory UI
-- Product categories (hierarchical, optional)
CREATE TABLE IF NOT EXISTS categories (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
parent_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_categories_parent ON categories(parent_id);
CREATE INDEX IF NOT EXISTS idx_categories_active ON categories(is_active);
-- Tier-based discount configuration for price tiers 2 and 3
CREATE TABLE IF NOT EXISTS tier_discounts (
tier_id INTEGER PRIMARY KEY,
tier_name VARCHAR(50) NOT NULL,
discount_pct NUMERIC(5,2) DEFAULT 0
);

View File

@@ -1,11 +0,0 @@
-- v4.6 Workshop business fields
-- Adds delivery method, courier assignment and direct-order flag to service orders.
ALTER TABLE service_orders
ADD COLUMN IF NOT EXISTS delivery_method VARCHAR(30),
ADD COLUMN IF NOT EXISTS courier_id INTEGER REFERENCES couriers(id),
ADD COLUMN IF NOT EXISTS is_direct BOOLEAN DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_service_orders_delivery_method ON service_orders(delivery_method);
CREATE INDEX IF NOT EXISTS idx_service_orders_courier_id ON service_orders(courier_id);
CREATE INDEX IF NOT EXISTS idx_service_orders_is_direct ON service_orders(is_direct);

View File

@@ -1,15 +0,0 @@
-- v4.8 Workshop permissions seed
-- Grants workshop permissions to existing admin employees so they keep access
-- after the new role-based restrictions are enforced.
INSERT INTO employee_permissions (employee_id, permission)
SELECT id, 'workshop.view'
FROM employees
WHERE role = 'admin'
ON CONFLICT DO NOTHING;
INSERT INTO employee_permissions (employee_id, permission)
SELECT id, 'workshop.edit'
FROM employees
WHERE role = 'admin'
ON CONFLICT DO NOTHING;

View File

@@ -1,7 +0,0 @@
-- v4.9: Grant customers.view permission to existing workshop employees
-- so they can load the customer list when creating service orders.
INSERT INTO employee_permissions (employee_id, permission)
SELECT e.id, 'customers.view'
FROM employees e
WHERE e.role = 'workshop'
ON CONFLICT (employee_id, permission) DO NOTHING;

View File

@@ -46,22 +46,18 @@ def _get_account_id(cur, code):
def _get_account_ids(cur, codes): def _get_account_ids(cur, codes):
"""Look up multiple account IDs by code. Returns dict {code: id}.""" """Look up multiple account IDs by code. Returns dict {code: id}."""
cur.execute( result = {}
"SELECT code, id FROM accounts WHERE code = ANY(%s) AND is_active = true", for code in codes:
(list(codes),) result[code] = _get_account_id(cur, code)
) return result
rows = {row[0]: row[1] for row in cur.fetchall()}
missing = set(codes) - set(rows)
if missing:
raise ValueError(f"Account(s) with code {sorted(missing)} not found")
return rows
def get_next_entry_number(conn): def get_next_entry_number(conn):
"""Get the next sequential journal entry number. """Get the next sequential journal entry number.
Uses a transaction-level advisory lock to prevent duplicate numbers Uses a simple MAX+1 approach. For high-concurrency environments this
when multiple journal entries are created concurrently. could be replaced with a sequence, but for single-tenant refaccionarias
the transaction-level lock from the INSERT is sufficient.
Args: Args:
conn: psycopg2 connection to tenant DB conn: psycopg2 connection to tenant DB
@@ -70,7 +66,6 @@ def get_next_entry_number(conn):
int: next entry number (starts at 1) int: next entry number (starts at 1)
""" """
cur = conn.cursor() cur = conn.cursor()
cur.execute("SELECT pg_advisory_xact_lock(hashtext('journal_entry_number'))")
cur.execute("SELECT COALESCE(MAX(entry_number), 0) + 1 FROM journal_entries") cur.execute("SELECT COALESCE(MAX(entry_number), 0) + 1 FROM journal_entries")
number = cur.fetchone()[0] number = cur.fetchone()[0]
cur.close() cur.close()

View File

@@ -29,13 +29,8 @@ MAX_RETRIES = len(BACKOFF_INTERVALS)
def _generate_provisional_folio(conn): def _generate_provisional_folio(conn):
"""Generate a provisional folio like PRE-00001. """Generate a provisional folio like PRE-00001."""
Uses a transaction-level advisory lock to avoid duplicate provisional
folios when multiple CFDIs are enqueued concurrently.
"""
cur = conn.cursor() cur = conn.cursor()
cur.execute("SELECT pg_advisory_xact_lock(hashtext('cfdi_provisional_folio'))")
cur.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM cfdi_queue") cur.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM cfdi_queue")
seq = cur.fetchone()[0] seq = cur.fetchone()[0]
cur.close() cur.close()
@@ -106,7 +101,6 @@ def process_queue(conn, tenant_config, dry_run=False):
AND retry_count < %s AND retry_count < %s
ORDER BY created_at ASC ORDER BY created_at ASC
LIMIT 50 LIMIT 50
FOR UPDATE SKIP LOCKED
""", """,
(MAX_RETRIES,), (MAX_RETRIES,),
) )
@@ -370,7 +364,7 @@ def get_queue_status(conn, filters=None):
params.append(int(filters["sale_id"])) params.append(int(filters["sale_id"]))
if filters.get("type"): if filters.get("type"):
where_clauses.append("LOWER(q.type) = LOWER(%s)") where_clauses.append("q.type = %s")
params.append(filters["type"]) params.append(filters["type"])
where = " AND ".join(where_clauses) where = " AND ".join(where_clauses)
@@ -382,12 +376,8 @@ def get_queue_status(conn, filters=None):
f""" f"""
SELECT q.id, q.sale_id, q.type, q.uuid_fiscal, q.status, SELECT q.id, q.sale_id, q.type, q.uuid_fiscal, q.status,
q.retry_count, q.provisional_folio, q.error_message, q.retry_count, q.provisional_folio, q.error_message,
q.cancel_motive, q.created_at, q.stamped_at, q.external_id, q.cancel_motive, q.created_at, q.stamped_at, q.external_id
c.name as customer_name, c.rfc,
s.subtotal, s.tax_total, s.total, s.payment_method
FROM cfdi_queue q FROM cfdi_queue q
LEFT JOIN sales s ON q.sale_id = s.id
LEFT JOIN customers c ON s.customer_id = c.id
WHERE {where} WHERE {where}
ORDER BY q.created_at DESC ORDER BY q.created_at DESC
LIMIT %s OFFSET %s LIMIT %s OFFSET %s
@@ -411,12 +401,6 @@ def get_queue_status(conn, filters=None):
"created_at": str(r[9]) if r[9] else None, "created_at": str(r[9]) if r[9] else None,
"stamped_at": str(r[10]) if r[10] else None, "stamped_at": str(r[10]) if r[10] else None,
"external_id": r[11], "external_id": r[11],
"customer_name": r[12],
"rfc": r[13],
"subtotal": float(r[14]) if r[14] else 0,
"tax_total": float(r[15]) if r[15] else 0,
"total": float(r[16]) if r[16] else 0,
"payment_method": r[17],
} }
) )

View File

@@ -206,7 +206,7 @@ def find_organization_by_rfc(tenant_config: dict) -> dict | None:
def create_organization(tenant_config: dict) -> dict: def create_organization(tenant_config: dict) -> dict:
"""Create or reuse a Facturapi organization, configure legal data and return a live key. """Create a new Facturapi organization for the tenant and return live key.
Requires FACTURAPI_USER_KEY env or a user key (sk_user_*) in tenant_config. Requires FACTURAPI_USER_KEY env or a user key (sk_user_*) in tenant_config.
Uses tenant RFC/razon_social if available. Uses tenant RFC/razon_social if available.
@@ -216,18 +216,8 @@ def create_organization(tenant_config: dict) -> dict:
rfc = (tenant_config.get("rfc") or "").upper().strip() rfc = (tenant_config.get("rfc") or "").upper().strip()
name = tenant_config.get("razon_social") or tenant_config.get("name") or rfc or "Nexus" name = tenant_config.get("razon_social") or tenant_config.get("name") or rfc or "Nexus"
# 1) Reuse the organization already stored locally, if it still exists in Facturapi. # First try to find existing org by RFC
local_org_id = _get_org_id(tenant_config) existing = find_organization_by_rfc(tenant_config) if rfc else None
if local_org_id:
try:
get_organization(local_org_id, user_key)
org_id = local_org_id
except FacturapiError:
local_org_id = None
# 2) Try to find an existing organization by RFC (legacy / Horux-style lookup).
if not local_org_id and rfc:
existing = find_organization_by_rfc(tenant_config)
if existing: if existing:
org_id = existing["id"] org_id = existing["id"]
else: else:
@@ -236,20 +226,6 @@ def create_organization(tenant_config: dict) -> dict:
org_id = org.get("id") org_id = org.get("id")
if not org_id: if not org_id:
raise FacturapiError("Could not create organization: no id returned") raise FacturapiError("Could not create organization: no id returned")
elif not local_org_id:
payload = {"name": name}
org = _request("POST", "/organizations", user_key, json_payload=payload, timeout=60)
org_id = org.get("id")
if not org_id:
raise FacturapiError("Could not create organization: no id returned")
# Configure fiscal/legal data (required before Live mode can be used)
try:
update_organization_legal(tenant_config, org_id)
except FacturapiError:
# If legal update fails, still return org info so the caller can surface it,
# but do not generate a live key because it would be unusable.
return {"org_id": org_id, "api_key": None, "legal_updated": False}
# Generate live secret key # Generate live secret key
key_resp = _request("PUT", f"/organizations/{org_id}/apikeys/live", user_key, json_payload={}, timeout=60) key_resp = _request("PUT", f"/organizations/{org_id}/apikeys/live", user_key, json_payload={}, timeout=60)
@@ -257,80 +233,7 @@ def create_organization(tenant_config: dict) -> dict:
if not live_key: if not live_key:
raise FacturapiError(f"Could not generate live key for org {org_id}") raise FacturapiError(f"Could not generate live key for org {org_id}")
return {"org_id": org_id, "api_key": live_key, "legal_updated": True} return {"org_id": org_id, "api_key": live_key}
def _build_legal_payload(tenant_config: dict) -> dict:
"""Build Facturapi /organizations/{id}/legal payload from tenant config.
Facturapi expects `name`, `legal_name`, `tax_system` and an `address` object
with at least `street` and `exterior` non-empty. `tax_id` is not accepted
by this endpoint (it is set from the CSD or from the organization profile).
"""
rfc = (tenant_config.get("rfc") or "").upper().strip()
legal_name = (tenant_config.get("razon_social") or "").strip()
tax_system = (tenant_config.get("regimen_fiscal") or "").strip() or "601"
zip_code = (tenant_config.get("cp") or "").strip() or "00000"
if not rfc or not legal_name:
raise FacturapiError("RFC y Razón Social son obligatorios para configurar la organización en Facturapi")
name = legal_name or rfc or "Nexus"
street = (tenant_config.get("direccion") or "").strip() or "No especificada"
exterior = (
(tenant_config.get("exterior") or "").strip()
or (tenant_config.get("numero_exterior") or "").strip()
or "S/N"
)
address = {
"zip": zip_code,
"street": street,
"exterior": exterior,
}
optional_address_fields = {
"interior": (tenant_config.get("interior") or tenant_config.get("numero_interior") or "").strip(),
"neighborhood": (tenant_config.get("colonia") or "").strip(),
"city": (tenant_config.get("ciudad") or "").strip(),
"municipality": (tenant_config.get("municipio") or "").strip(),
"state": (tenant_config.get("estado") or "").strip(),
}
for key, value in optional_address_fields.items():
if value:
address[key] = value
return {
"name": name,
"legal_name": legal_name,
"tax_system": tax_system,
"address": address,
}
def update_organization_legal(tenant_config: dict, org_id: str) -> dict:
"""Update the legal/fiscal data of a Facturapi organization.
Uses the user key (admin) because the org may not have a live key yet.
"""
user_key = _get_user_key_for_tenant(tenant_config)
payload = _build_legal_payload(tenant_config)
return _request("PUT", f"/organizations/{org_id}/legal", user_key, json_payload=payload, timeout=60)
def _get_status_key(tenant_config: dict) -> str | None:
"""Return the best key for read-only organization status checks.
Prefer the Facturapi user key because the live secret key cannot read
organization metadata until the org is production-ready.
"""
user = _get_user_key()
if user:
return user
for key in ("facturapi_key", "cfdi_facturapi_key"):
tenant_key = (tenant_config.get(key) or "").strip()
if tenant_key.startswith("sk_user_"):
return tenant_key
return _get_secret_key(tenant_config)
def get_org_status(tenant_config: dict) -> dict: def get_org_status(tenant_config: dict) -> dict:
@@ -346,12 +249,12 @@ def get_org_status(tenant_config: dict) -> dict:
"error": None, "error": None,
} }
has_secret = bool(_get_secret_key(tenant_config)) try:
has_user = bool(_get_user_key_for_tenant(tenant_config)) api_key = get_api_key(tenant_config)
if not has_secret and not has_user:
result["error"] = "Facturapi not configured. Set FACTURAPI_USER_KEY env or tenant_config.facturapi_secret_key"
return result
result["has_key"] = True result["has_key"] = True
except FacturapiError as e:
result["error"] = str(e)
return result
org_id = _get_org_id(tenant_config) org_id = _get_org_id(tenant_config)
if not org_id: if not org_id:
@@ -361,35 +264,20 @@ def get_org_status(tenant_config: dict) -> dict:
result["has_org_id"] = True result["has_org_id"] = True
result["org_id"] = org_id result["org_id"] = org_id
api_key = _get_status_key(tenant_config) try:
if not api_key:
result["error"] = "No Facturapi key available"
return result
def _fetch():
org = get_organization(org_id, api_key) org = get_organization(org_id, api_key)
legal = org.get("legal", {}) legal = org.get("legal", {})
cert = org.get("certificate", {}) cert = org.get("certificate", {})
return { result.update(
{
"configured": True, "configured": True,
"has_csd": bool(cert.get("has_certificate")), "has_csd": bool(cert.get("has_certificate")),
"legal_name": legal.get("name") or legal.get("legal_name"), "legal_name": legal.get("name") or legal.get("legal_name"),
"tax_id": legal.get("tax_id"), "tax_id": legal.get("tax_id"),
"pending_steps": org.get("pending_steps", []), "pending_steps": org.get("pending_steps", []),
} }
)
try:
result.update(_fetch())
except FacturapiError as e: except FacturapiError as e:
# If the org reports "not configured for Live" (401) and we have a user key,
# try to backfill legal data and retry.
if e.status_code == 401 and _get_user_key_for_tenant(tenant_config):
try:
update_organization_legal(tenant_config, org_id)
result.update(_fetch())
except FacturapiError as e2:
result["error"] = str(e2)
else:
result["error"] = str(e) result["error"] = str(e)
return result return result

View File

@@ -58,18 +58,19 @@ def get_eligible_sales(conn, year, month, branch_id=None, max_total=2000):
cur.close() cur.close()
return [] return []
# Load sale details with items in two bulk queries (O(1) round-trips) # Load sale details with items
sales = []
for sale_id in sale_ids:
cur.execute(""" cur.execute("""
SELECT id, branch_id, customer_id, employee_id, sale_type, SELECT id, branch_id, customer_id, employee_id, sale_type,
payment_method, subtotal, discount_total, tax_total, total, payment_method, subtotal, discount_total, tax_total, total,
metodo_pago_sat, forma_pago_sat, status, created_at metodo_pago_sat, forma_pago_sat, status, created_at
FROM sales FROM sales WHERE id = %s
WHERE id = ANY(%s) """, (sale_id,))
ORDER BY created_at ASC row = cur.fetchone()
""", (sale_ids,)) if not row:
continue
sales = {}
for row in cur.fetchall():
sale = { sale = {
'id': row[0], 'branch_id': row[1], 'customer_id': row[2], 'id': row[0], 'branch_id': row[1], 'customer_id': row[2],
'employee_id': row[3], 'sale_type': row[4], 'employee_id': row[3], 'sale_type': row[4],
@@ -84,37 +85,33 @@ def get_eligible_sales(conn, year, month, branch_id=None, max_total=2000):
'created_at': str(row[13]), 'created_at': str(row[13]),
'items': [], 'items': [],
} }
sales[row[0]] = sale
cur.execute(""" cur.execute("""
SELECT id, sale_id, inventory_id, part_number, name, quantity, unit_price, SELECT id, inventory_id, part_number, name, quantity, unit_price,
unit_cost, discount_pct, discount_amount, tax_rate, tax_amount, unit_cost, discount_pct, discount_amount, tax_rate, tax_amount,
subtotal, clave_prod_serv, clave_unidad subtotal, clave_prod_serv, clave_unidad
FROM sale_items FROM sale_items WHERE sale_id = %s ORDER BY id
WHERE sale_id = ANY(%s) """, (sale_id,))
ORDER BY sale_id, id
""", (sale_ids,))
for r in cur.fetchall(): for r in cur.fetchall():
sale = sales.get(r[1])
if not sale:
continue
sale['items'].append({ sale['items'].append({
'id': r[0], 'inventory_id': r[2], 'part_number': r[3], 'id': r[0], 'inventory_id': r[1], 'part_number': r[2],
'name': r[4], 'quantity': r[5], 'name': r[3], 'quantity': r[4],
'unit_price': float(r[6]) if r[6] else 0, 'unit_price': float(r[5]) if r[5] else 0,
'unit_cost': float(r[7]) if r[7] else 0, 'unit_cost': float(r[6]) if r[6] else 0,
'discount_pct': float(r[8]) if r[8] else 0, 'discount_pct': float(r[7]) if r[7] else 0,
'discount_amount': float(r[9]) if r[9] else 0, 'discount_amount': float(r[8]) if r[8] else 0,
'tax_rate': float(r[10]) if r[10] else 0.16, 'tax_rate': float(r[9]) if r[9] else 0.16,
'tax_amount': float(r[11]) if r[11] else 0, 'tax_amount': float(r[10]) if r[10] else 0,
'subtotal': float(r[12]) if r[12] else 0, 'subtotal': float(r[11]) if r[11] else 0,
'clave_prod_serv': r[13] or '25174800', 'clave_prod_serv': r[12] or '25174800',
'clave_unidad': r[14] or 'H87', 'clave_unidad': r[13] or 'H87',
}) })
sales.append(sale)
cur.close() cur.close()
return list(sales.values()) return sales
def generate_global_invoice(conn, tenant_config, year, month, branch_id=None, def generate_global_invoice(conn, tenant_config, year, month, branch_id=None,

View File

@@ -122,14 +122,7 @@ def record_operation(conn, inventory_id, branch_id, operation_type, quantity,
)) ))
op_id = cur.fetchone()[0] op_id = cur.fetchone()[0]
# Queue ML stock sync if this product has an active ML listing. # Queue ML stock sync if this product has an active ML listing
# Skip gracefully if the marketplace tables do not exist in this tenant.
cur.execute("""
SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name IN ('marketplace_listings', 'meli_sync_queue')
""")
if cur.fetchone()[0] >= 2:
cur.execute(""" cur.execute("""
INSERT INTO meli_sync_queue (inventory_id, action, status) INSERT INTO meli_sync_queue (inventory_id, action, status)
SELECT %s, 'stock_update', 'pending' SELECT %s, 'stock_update', 'pending'
@@ -237,54 +230,6 @@ def record_sale(conn, inventory_id, branch_id, quantity, sale_id=None, cost_at_t
return op_id return op_id
def record_reservation(conn, inventory_id, branch_id, quantity, sale_id=None,
cost_at_time=None, remaining_stock=None):
"""Reserve stock for a counter remission note (negative quantity).
The reserved quantity is deducted from available stock until the note is
paid or cancelled.
"""
op_id = record_operation(
conn, inventory_id, branch_id, 'REMISSION_RESERVE', -abs(quantity),
reference_id=sale_id, reference_type='sale', cost_at_time=cost_at_time,
notes='Reserva por nota de remision'
)
invalidate_stock(inventory_id, branch_id)
invalidate_stock(inventory_id, None)
try:
remaining = remaining_stock if remaining_stock is not None else get_stock(conn, inventory_id, branch_id)
if remaining <= 0:
cur = conn.cursor()
cur.execute("SELECT part_number, name FROM inventory WHERE id = %s", (inventory_id,))
inv_row = cur.fetchone()
cur.close()
if inv_row:
from services.push_service import notify_owner
notify_owner(
conn,
'Stock en Cero',
f'{inv_row[1] or inv_row[0]} se quedo sin existencias',
'/pos'
)
except Exception:
pass
return op_id
def release_reservation(conn, inventory_id, branch_id, quantity, sale_id=None, notes=None):
"""Release a previous reservation (positive quantity)."""
result = record_operation(
conn, inventory_id, branch_id, 'REMISSION_RELEASE', abs(quantity),
reference_id=sale_id, reference_type='sale',
notes=notes or 'Liberacion de reserva de nota de remision'
)
invalidate_stock(inventory_id, branch_id)
invalidate_stock(inventory_id, None)
return result
def record_return(conn, inventory_id, branch_id, quantity, sale_id=None, notes=None): def record_return(conn, inventory_id, branch_id, quantity, sale_id=None, notes=None):
"""Record a customer return (positive quantity).""" """Record a customer return (positive quantity)."""
result = record_operation( result = record_operation(
@@ -429,7 +374,7 @@ def get_movement_history(conn, inventory_id, limit=50):
history.append({ history.append({
'id': r[0], 'type': r[1], 'quantity': r[2], 'id': r[0], 'type': r[1], 'quantity': r[2],
'cost': float(r[3]) if r[3] else None, 'cost': float(r[3]) if r[3] else None,
'notes': r[4], 'date': r[5].isoformat() if r[5] else None, 'notes': r[4], 'date': str(r[5]),
'employee': r[6], 'branch_id': r[7] 'employee': r[6], 'branch_id': r[7]
}) })
cur.close() cur.close()

View File

@@ -11,13 +11,10 @@ Tax: 16% IVA per item (from item.tax_rate field).
from datetime import datetime, timedelta from datetime import datetime, timedelta
from decimal import Decimal, ROUND_HALF_UP from decimal import Decimal, ROUND_HALF_UP
import threading
from flask import g from flask import g
from services.audit import log_action from services.audit import log_action
from services.inventory_engine import ( from services.inventory_engine import (
record_sale as inventory_record_sale, record_sale as inventory_record_sale,
record_reservation as inventory_record_reservation,
release_reservation as inventory_release_reservation,
record_operation, record_operation,
get_stock, get_stock,
get_stock_bulk, get_stock_bulk,
@@ -226,9 +223,9 @@ def process_sale(conn, sale_data):
if not items: if not items:
raise ValueError("No items in sale") raise ValueError("No items in sale")
# Validate register is open and lock it to prevent concurrent close/sale races # Validate register is open
if register_id: if register_id:
cur.execute("SELECT status FROM cash_registers WHERE id = %s FOR UPDATE", (register_id,)) cur.execute("SELECT status FROM cash_registers WHERE id = %s", (register_id,))
reg = cur.fetchone() reg = cur.fetchone()
if not reg or reg[0] != 'open': if not reg or reg[0] != 'open':
raise ValueError("Cash register is not open") raise ValueError("Cash register is not open")
@@ -252,17 +249,6 @@ def process_sale(conn, sale_data):
# Batch stock check # Batch stock check
stock_map = get_stock_bulk(conn, branch_id) stock_map = get_stock_bulk(conn, branch_id)
# Lock per-branch stock rows and refresh stock map to prevent overselling
# on concurrent sales of the same items.
if branch_id:
cur.execute("""
SELECT inventory_id, stock
FROM inventory_stock
WHERE branch_id = %s AND inventory_id = ANY(%s)
FOR UPDATE
""", (branch_id, inv_ids))
stock_map = {r[0]: r[1] for r in cur.fetchall()}
# Validate and enrich items # Validate and enrich items
enriched_items = [] enriched_items = []
for item in items: for item in items:
@@ -315,34 +301,24 @@ def process_sale(conn, sale_data):
credit_limit = float(cust[0] or 0) credit_limit = float(cust[0] or 0)
credit_balance = float(cust[1] or 0) credit_balance = float(cust[1] or 0)
credit_available = credit_limit - credit_balance credit_available = credit_limit - credit_balance
if totals['total'] > credit_available: if totals['total'] > credit_available and credit_limit > 0:
raise ValueError( raise ValueError(
f"Insufficient credit. Available: ${credit_available:.2f}, " f"Insufficient credit. Available: ${credit_available:.2f}, "
f"Required: ${totals['total']:.2f}" f"Required: ${totals['total']:.2f}"
) )
# Pending payment sale (e.g. "Pendiente")
is_pending = payment_method == 'pendiente'
if is_pending:
amount_paid = 0.0
sale_type = 'cash'
# Calculate change # Calculate change
change_given = 0.0 change_given = 0.0
if sale_type == 'cash' and payment_method == 'efectivo': if sale_type == 'cash' and payment_method == 'efectivo':
change_given = round(max(amount_paid - totals['total'], 0), 2) change_given = round(max(amount_paid - totals['total'], 0), 2)
# SAT payment method codes # SAT payment method codes
metodo_pago_sat = 'PPD' if sale_type == 'credit' or is_pending else 'PUE' metodo_pago_sat = 'PPD' if sale_type == 'credit' else 'PUE'
forma_pago_map = { forma_pago_map = {
'efectivo': '01', 'cheque': '02', 'transferencia': '03', 'efectivo': '01', 'transferencia': '03', 'tarjeta': '04', 'mixto': '99'
'tarjeta': '04', 'mixto': '99', 'pendiente': '99', 'credito': '99'
} }
forma_pago_sat = forma_pago_map.get(payment_method, '99') forma_pago_sat = forma_pago_map.get(payment_method, '99')
# Determine sale status
status = 'pending_payment' if is_pending else 'completed'
# Create sale record (with currency) # Create sale record (with currency)
cur.execute(""" cur.execute("""
INSERT INTO sales INSERT INTO sales
@@ -350,14 +326,14 @@ def process_sale(conn, sale_data):
payment_method, subtotal, discount_total, tax_total, total, payment_method, subtotal, discount_total, tax_total, total,
amount_paid, change_given, metodo_pago_sat, forma_pago_sat, amount_paid, change_given, metodo_pago_sat, forma_pago_sat,
status, device_id, notes, currency, exchange_rate) status, device_id, notes, currency, exchange_rate)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'completed',%s,%s,%s,%s)
RETURNING id, created_at RETURNING id, created_at
""", ( """, (
branch_id, customer_id, employee_id, register_id, sale_type, branch_id, customer_id, employee_id, register_id, sale_type,
payment_method, totals['subtotal'], totals['discount_total'], payment_method, totals['subtotal'], totals['discount_total'],
totals['tax_total'], totals['total'], amount_paid, change_given, totals['tax_total'], totals['total'], amount_paid, change_given,
metodo_pago_sat, forma_pago_sat, metodo_pago_sat, forma_pago_sat,
status, _safe_g('device_id'), notes, _safe_g('device_id'), notes,
currency, exchange_rate currency, exchange_rate
)) ))
sale_id, created_at = cur.fetchone() sale_id, created_at = cur.fetchone()
@@ -416,21 +392,18 @@ def process_sale(conn, sale_data):
'subtotal': item['subtotal'], 'subtotal': item['subtotal'],
}) })
# Record payment on cash register (skip pending sales and zero-amount rows) # Record payment on cash register (cash movements for efectivo)
if not is_pending:
if register_id and payment_details: if register_id and payment_details:
for pd in payment_details: for pd in payment_details:
method = pd.get('method', payment_method) method = pd.get('method', payment_method)
amt = float(pd.get('amount', 0)) amt = float(pd.get('amount', 0))
ref = pd.get('reference', '') ref = pd.get('reference', '')
if amt <= 0:
continue
cur.execute(""" cur.execute("""
INSERT INTO sale_payments INSERT INTO sale_payments
(sale_id, register_id, method, amount, reference, currency, exchange_rate) (sale_id, register_id, method, amount, reference, currency, exchange_rate)
VALUES (%s,%s,%s,%s,%s,%s,%s) VALUES (%s,%s,%s,%s,%s,%s,%s)
""", (sale_id, register_id, method, amt, ref, currency, exchange_rate)) """, (sale_id, register_id, method, amt, ref, currency, exchange_rate))
elif register_id and amount_paid > 0: elif register_id:
cur.execute(""" cur.execute("""
INSERT INTO sale_payments INSERT INTO sale_payments
(sale_id, register_id, method, amount, reference, currency, exchange_rate) (sale_id, register_id, method, amount, reference, currency, exchange_rate)
@@ -445,16 +418,6 @@ def process_sale(conn, sale_data):
WHERE id = %s WHERE id = %s
""", (totals['total'], customer_id)) """, (totals['total'], customer_id))
# Fetch customer info for ticket/receipt
customer_name = None
customer_rfc = None
if customer_id:
cur.execute("SELECT name, rfc FROM customers WHERE id = %s", (customer_id,))
cust_row = cur.fetchone()
if cust_row:
customer_name = cust_row[0]
customer_rfc = cust_row[1]
# Audit log # Audit log
log_action(conn, 'SALE', 'sale', sale_id, log_action(conn, 'SALE', 'sale', sale_id,
new_value={ new_value={
@@ -531,8 +494,6 @@ def process_sale(conn, sale_data):
'id': sale_id, 'id': sale_id,
'branch_id': branch_id, 'branch_id': branch_id,
'customer_id': customer_id, 'customer_id': customer_id,
'customer_name': customer_name,
'customer_rfc': customer_rfc,
'employee_id': employee_id, 'employee_id': employee_id,
'register_id': register_id, 'register_id': register_id,
'sale_type': sale_type, 'sale_type': sale_type,
@@ -553,342 +514,6 @@ def process_sale(conn, sale_data):
} }
def create_remission_note(conn, data):
"""Create a counter remission note: reserve stock, no payment, pending status.
data: {
items: [{inventory_id, quantity, unit_price, discount_pct, tax_rate}],
customer_id: int | null,
notes: str,
branch_id: int,
register_id: int | null (optional)
}
"""
cur = conn.cursor()
items = data.get('items', [])
customer_id = data.get('customer_id')
notes = data.get('notes')
branch_id = data.get('branch_id') or _safe_g('branch_id')
register_id = data.get('register_id')
employee_id = _safe_g('employee_id')
currency = data.get('currency', 'MXN')
if currency not in ('MXN', 'USD'):
raise ValueError("Unsupported currency")
exchange_rate = float(data.get('exchange_rate') or 1.0)
if currency != 'MXN' and not exchange_rate:
exchange_rate = float(get_exchange_rate(conn, currency, 'MXN'))
if not branch_id:
cur.execute("SELECT id FROM branches WHERE is_main = true AND is_active = true LIMIT 1")
row = cur.fetchone()
branch_id = row[0] if row else None
if not branch_id:
raise ValueError("No hay sucursal activa disponible")
if not items:
raise ValueError("No items in remission note")
inv_ids = [item.get('inventory_id') for item in items]
cur.execute("""
SELECT id, part_number, name, cost, price_1, price_2, price_3,
tax_rate, branch_id, retail_price
FROM inventory
WHERE id = ANY(%s) AND is_active = true
ORDER BY id
FOR UPDATE
""", (inv_ids,))
inv_rows = {r[0]: r for r in cur.fetchall()}
stock_map = get_stock_bulk(conn, branch_id)
if branch_id:
cur.execute("""
SELECT inventory_id, stock
FROM inventory_stock
WHERE branch_id = %s AND inventory_id = ANY(%s)
FOR UPDATE
""", (branch_id, inv_ids))
stock_map = {r[0]: r[1] for r in cur.fetchall()}
enriched_items = []
for item in items:
inv_id = item.get('inventory_id')
qty = int(item.get('quantity', 1))
if qty <= 0:
raise ValueError(f"Invalid quantity for inventory_id {inv_id}")
inv = inv_rows.get(inv_id)
if not inv:
raise ValueError(f"Inventory item {inv_id} not found or inactive")
current_stock = stock_map.get(inv_id, 0)
unit_price = float(item.get('unit_price', inv[4]))
discount_pct = float(item.get('discount_pct', 0))
tax_rate = float(item.get('tax_rate', inv[7] or 0.16))
unit_cost = float(inv[3]) if inv[3] else 0
enriched_items.append({
'inventory_id': inv_id,
'part_number': inv[1],
'name': inv[2],
'quantity': qty,
'unit_price': unit_price,
'unit_cost': unit_cost,
'discount_pct': discount_pct,
'tax_rate': tax_rate,
'branch_id': inv[8],
'stock_before': current_stock,
})
totals = calculate_totals(enriched_items)
cur.execute("""
INSERT INTO sales
(branch_id, customer_id, employee_id, register_id, sale_type,
payment_method, subtotal, discount_total, tax_total, total,
amount_paid, change_given, status, device_id, notes, currency, exchange_rate,
courier_id)
VALUES (%s, %s, %s, %s, 'counter_remission', 'remission',
%s, %s, %s, %s, 0, 0, 'pending_payment',
%s, %s, %s, %s, %s)
RETURNING id, created_at
""", (
branch_id, customer_id, employee_id, register_id,
totals['subtotal'], totals['discount_total'], totals['tax_total'], totals['total'],
_safe_g('device_id'), notes, currency, exchange_rate,
data.get('courier_id')
))
sale_id, created_at = cur.fetchone()
sale_items_data = []
for item in totals['items']:
inv = inv_rows.get(item['inventory_id'])
retail_price = inv[9] if inv else None
sale_items_data.append((
sale_id, item['inventory_id'], item['part_number'], item['name'],
item['quantity'], item['unit_price'], item.get('unit_cost', 0),
item['discount_pct'], item['discount_amount'],
item['tax_rate'], item['tax_amount'], item['subtotal'],
retail_price, currency, exchange_rate
))
cur.executemany("""
INSERT INTO sale_items
(sale_id, inventory_id, part_number, name, quantity,
unit_price, unit_cost, discount_pct, discount_amount,
tax_rate, tax_amount, subtotal, retail_price, currency, exchange_rate)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", sale_items_data)
sale_items = []
for item in totals['items']:
stock_before = next((i['stock_before'] for i in enriched_items if i['inventory_id'] == item['inventory_id']), 0)
remaining_after = stock_before - item['quantity']
inventory_record_reservation(
conn,
item['inventory_id'],
item.get('branch_id', branch_id),
item['quantity'],
sale_id=sale_id,
cost_at_time=item.get('unit_cost'),
remaining_stock=remaining_after
)
sale_items.append({
'inventory_id': item['inventory_id'],
'part_number': item['part_number'],
'name': item['name'],
'quantity': item['quantity'],
'unit_price': item['unit_price'],
'unit_cost': item.get('unit_cost', 0),
'discount_pct': item['discount_pct'],
'discount_amount': item['discount_amount'],
'tax_rate': item['tax_rate'],
'tax_amount': item['tax_amount'],
'subtotal': item['subtotal'],
})
log_action(conn, 'REMISSION_CREATED', 'sale', sale_id,
new_value={
'total': totals['total'],
'items_count': len(sale_items),
'customer_id': customer_id,
})
# Fetch customer info for ticket/receipt
customer_name = None
customer_rfc = None
if customer_id:
cur.execute("SELECT name, rfc FROM customers WHERE id = %s", (customer_id,))
cust_row = cur.fetchone()
if cust_row:
customer_name = cust_row[0]
customer_rfc = cust_row[1]
cur.close()
return {
'id': sale_id,
'branch_id': branch_id,
'customer_id': customer_id,
'customer_name': customer_name,
'customer_rfc': customer_rfc,
'employee_id': employee_id,
'register_id': register_id,
'sale_type': 'counter_remission',
'payment_method': 'remission',
'subtotal': totals['subtotal'],
'discount_total': totals['discount_total'],
'tax_total': totals['tax_total'],
'total': totals['total'],
'amount_paid': 0.0,
'change_given': 0.0,
'status': 'pending_payment',
'courier_id': data.get('courier_id'),
'items': sale_items,
'created_at': str(created_at),
'currency': currency,
'exchange_rate': exchange_rate,
}
def pay_pending_sale(conn, sale_id, data):
"""Pay a pending counter remission note and convert it into a completed sale.
data: {
payment_method: str,
amount_paid: float,
payment_details: [{method, amount, reference}],
register_id: int,
reference: str
}
"""
cur = conn.cursor()
cur.execute("""
SELECT id, branch_id, customer_id, employee_id, subtotal, tax_total, total, status, sale_type,
currency, exchange_rate
FROM sales WHERE id = %s
FOR UPDATE
""", (sale_id,))
row = cur.fetchone()
if not row:
raise ValueError("Sale not found")
(sale_id, branch_id, customer_id, employee_id, subtotal, tax_total, total, status, sale_type,
currency, exchange_rate) = row
# Fetch customer info for ticket/receipt
customer_name = None
customer_rfc = None
if customer_id:
cur.execute("SELECT name, rfc FROM customers WHERE id = %s", (customer_id,))
cust_row = cur.fetchone()
if cust_row:
customer_name = cust_row[0]
customer_rfc = cust_row[1]
if status != 'pending_payment':
raise ValueError("La nota no esta pendiente de pago")
payment_method = data.get('payment_method', 'efectivo')
sale_type = 'cash'
amount_paid = float(data.get('amount_paid', 0))
payment_details = data.get('payment_details', [])
register_id = data.get('register_id')
reference = data.get('reference', '')
if register_id:
cur.execute("SELECT status FROM cash_registers WHERE id = %s FOR UPDATE", (register_id,))
reg = cur.fetchone()
if not reg or reg[0] != 'open':
raise ValueError("Cash register is not open")
totals = {'total': float(total)}
change_given = 0.0
if payment_method == 'efectivo':
change_given = round(max(amount_paid - totals['total'], 0), 2)
forma_pago_map = {'efectivo': '01', 'transferencia': '03', 'tarjeta': '04', 'mixto': '99'}
forma_pago_sat = forma_pago_map.get(payment_method, '99')
cur.execute("""
UPDATE sales
SET status = 'completed',
sale_type = %s,
payment_method = %s,
amount_paid = %s,
change_given = %s,
register_id = COALESCE(%s, register_id),
metodo_pago_sat = 'PUE',
forma_pago_sat = %s
WHERE id = %s
""", (sale_type, payment_method, amount_paid, change_given, register_id, forma_pago_sat, sale_id))
if payment_details:
for pd in payment_details:
method = pd.get('method', payment_method)
amt = float(pd.get('amount', 0))
ref = pd.get('reference', '')
cur.execute("""
INSERT INTO sale_payments
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""", (sale_id, register_id, method, amt, ref, currency, exchange_rate))
else:
cur.execute("""
INSERT INTO sale_payments
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""", (sale_id, register_id, payment_method, amount_paid, reference, currency, exchange_rate))
# Release reservation and record actual sale movement
cur.execute("""
SELECT inventory_id, quantity, unit_cost
FROM sale_items WHERE sale_id = %s ORDER BY id
""", (sale_id,))
for inv_id, qty, cost in cur.fetchall():
inventory_release_reservation(
conn, inv_id, branch_id, qty, sale_id=sale_id,
notes='Pago de nota de remision'
)
inventory_record_sale(
conn, inv_id, branch_id, qty,
sale_id=sale_id, cost_at_time=float(cost) if cost else None
)
# Accounting (non-blocking)
try:
total_mxn = to_mxn(float(total), currency, rate=exchange_rate, conn=conn)
tax_mxn = to_mxn(float(tax_total or 0), currency, rate=exchange_rate, conn=conn)
sub_mxn = to_mxn(float(subtotal or 0), currency, rate=exchange_rate, conn=conn)
cur.execute("""
SELECT COALESCE(SUM(unit_cost * quantity), 0)
FROM sale_items WHERE sale_id = %s
""", (sale_id,))
cost_total = float(cur.fetchone()[0] or 0)
record_sale_entry(conn, {
'id': sale_id,
'sale_type': sale_type,
'total': total_mxn,
'tax_total': tax_mxn,
'subtotal': sub_mxn,
'cost_total': cost_total,
'payment_method': payment_method,
})
except Exception:
pass
log_action(conn, 'REMISSION_PAID', 'sale', sale_id,
old_value={'status': 'pending_payment', 'total': totals['total']},
new_value={'status': 'completed', 'payment_method': payment_method})
cur.close()
return {
'id': sale_id,
'status': 'completed',
'payment_method': payment_method,
'amount_paid': amount_paid,
'change_given': change_given,
'total': totals['total'],
'customer_id': customer_id,
'customer_name': customer_name,
'customer_rfc': customer_rfc,
}
def cancel_sale(conn, sale_id, reason): def cancel_sale(conn, sale_id, reason):
"""Cancel a sale: validate permissions, reverse inventory, update credit. """Cancel a sale: validate permissions, reverse inventory, update credit.
@@ -930,15 +555,15 @@ def cancel_sale(conn, sale_id, reason):
if s_status == 'cancelled': if s_status == 'cancelled':
raise ValueError("Sale is already cancelled") raise ValueError("Sale is already cancelled")
# Permission check: non-admin employees can only cancel their own docs within 30 min # Permission check: cashiers can only cancel own sales within 30 min
role = _safe_g('employee_role', 'cashier') role = _safe_g('employee_role', 'cashier')
emp_id = _safe_g('employee_id') emp_id = _safe_g('employee_id')
if role not in ('owner', 'admin'): if role == 'cashier':
if s_emp_id != emp_id: if s_emp_id != emp_id:
raise ValueError("Solo puedes cancelar tus propias notas/ventas") raise ValueError("Cashiers can only cancel their own sales")
if datetime.utcnow() - s_created > timedelta(minutes=30): if datetime.utcnow() - s_created > timedelta(minutes=30):
raise ValueError("Solo puedes cancelar dentro de los primeros 30 minutos") raise ValueError("Cashiers can only cancel sales within 30 minutes of creation")
# Get sale items for inventory reversal # Get sale items for inventory reversal
cur.execute(""" cur.execute("""
@@ -947,16 +572,7 @@ def cancel_sale(conn, sale_id, reason):
""", (sale_id,)) """, (sale_id,))
sale_items = cur.fetchall() sale_items = cur.fetchall()
if s_status == 'pending_payment': # Reverse inventory: create RETURN operations (positive quantity)
# Pending remission: release reservation
for inv_id, qty, cost in sale_items:
inventory_release_reservation(
conn, inv_id, s_branch, qty,
sale_id=sale_id,
notes=f"Cancelacion nota de remision #{sale_id}: {reason}"
)
else:
# Completed sale: create RETURN operations (positive quantity)
from services.inventory_engine import record_return from services.inventory_engine import record_return
for inv_id, qty, cost in sale_items: for inv_id, qty, cost in sale_items:
record_return( record_return(
@@ -999,7 +615,7 @@ def cancel_sale(conn, sale_id, reason):
# Audit log # Audit log
log_action(conn, 'CANCEL', 'sale', sale_id, log_action(conn, 'CANCEL', 'sale', sale_id,
old_value={'status': s_status, 'total': float(s_total)}, old_value={'status': 'completed', 'total': float(s_total)},
new_value={'status': 'cancelled', 'reason': reason}) new_value={'status': 'cancelled', 'reason': reason})
# Push notification to owner/admin (best-effort, non-blocking) # Push notification to owner/admin (best-effort, non-blocking)

View File

@@ -8,66 +8,23 @@ from datetime import datetime
from services import inventory_engine from services import inventory_engine
def _tenant_allows_negative_stock(conn):
"""Return True if the tenant explicitly allows selling below zero stock."""
cur = conn.cursor()
cur.execute("SELECT value FROM tenant_config WHERE key = 'allow_negative_stock'")
row = cur.fetchone()
cur.close()
return row is not None and str(row[0]).lower() in ('true', '1', 'yes')
# Rached workshop statuses (applies to all tenants).
ORDER_STATUSES = [
'por_revisar',
'en_revision',
'revisada',
'cotizada',
'por_autorizar',
'autorizada',
'autorizacion_parcial',
'en_reparacion',
'reparada',
'por_entregar',
'entregado',
'por_enviar',
'enviado',
'por_facturar',
'facturada',
'por_recolectar',
'cancelada',
]
TERMINAL_STATUSES = {'entregado', 'facturada', 'cancelada'}
# Allow moving from any non-terminal status to any other non-terminal status,
# plus cancellation. Terminal statuses cannot change.
VALID_TRANSITIONS = { VALID_TRANSITIONS = {
status: [s for s in ORDER_STATUSES if s != status] 'received': ['diagnosis', 'cancelled'],
for status in ORDER_STATUSES 'diagnosis': ['waiting_parts', 'repair', 'cancelled'],
'waiting_parts': ['repair', 'cancelled'],
'repair': ['quality_check', 'cancelled'],
'quality_check': ['ready', 'repair', 'cancelled'],
'ready': ['delivered', 'cancelled'],
'delivered': [],
'cancelled': [],
} }
for terminal in TERMINAL_STATUSES:
VALID_TRANSITIONS[terminal] = []
# Legacy statuses (kept valid for imported/old data) cannot transition anywhere.
_LEGACY_STATUSES = {
'received', 'diagnosis', 'waiting_parts', 'repair', 'quality_check', 'ready', 'delivered'
}
for legacy in _LEGACY_STATUSES:
VALID_TRANSITIONS.setdefault(legacy, [])
def _generate_order_number(conn): def _generate_order_number(conn):
"""Generate DDMMYYYY-N order number (daily sequential). """Generate SO-YYYY-NNNN order number."""
Uses a per-day advisory transaction lock to avoid duplicate order
numbers when multiple workers create orders concurrently.
"""
cur = conn.cursor() cur = conn.cursor()
today = datetime.utcnow().strftime('%d%m%Y') year = datetime.utcnow().year
prefix = f"{today}-" prefix = f"SO-{year}-"
# Serialize order creation per day within the current transaction.
cur.execute("SELECT pg_advisory_xact_lock(hashtext(%s))", (prefix,))
cur.execute(""" cur.execute("""
SELECT order_number FROM service_orders SELECT order_number FROM service_orders
WHERE order_number LIKE %s WHERE order_number LIKE %s
@@ -80,19 +37,7 @@ def _generate_order_number(conn):
last_num = int(row[0].split('-')[-1]) last_num = int(row[0].split('-')[-1])
new_num = last_num + 1 new_num = last_num + 1
cur.close() cur.close()
return f"{prefix}{new_num}" return f"{prefix}{new_num:04d}"
_VALID_DELIVERY_METHODS = {'pickup', 'delivery', 'courier'}
def _normalize_delivery(data):
"""Restrict delivery_method to allowed values and clear courier when not applicable."""
dm = data.get('delivery_method')
if dm not in _VALID_DELIVERY_METHODS:
data['delivery_method'] = None
if data.get('delivery_method') not in ('delivery', 'courier'):
data['courier_id'] = None
def create_service_order(conn, data): def create_service_order(conn, data):
@@ -101,11 +46,9 @@ def create_service_order(conn, data):
data: { data: {
customer_id, vehicle_id, branch_id, priority, customer_id, vehicle_id, branch_id, priority,
reception_notes, estimated_cost, estimated_completion, reception_notes, estimated_cost, estimated_completion,
employee_id, mileage_in, fuel_level, created_by, employee_id, mileage_in, fuel_level, created_by
delivery_method, courier_id, is_direct, requires_invoice
} }
""" """
_normalize_delivery(data)
cur = conn.cursor() cur = conn.cursor()
order_number = _generate_order_number(conn) order_number = _generate_order_number(conn)
@@ -113,23 +56,16 @@ def create_service_order(conn, data):
INSERT INTO service_orders INSERT INTO service_orders
(tenant_id, branch_id, customer_id, vehicle_id, order_number, status, (tenant_id, branch_id, customer_id, vehicle_id, order_number, status,
priority, reception_notes, estimated_cost, estimated_completion, priority, reception_notes, estimated_cost, estimated_completion,
employee_id, mechanic_name, mileage_in, fuel_level, created_by, employee_id, mileage_in, fuel_level, created_by)
delivery_method, courier_id, is_direct, requires_invoice, VALUES (%s, %s, %s, %s, %s, 'received', %s, %s, %s, %s, %s, %s, %s, %s)
workshop_name, customer_address, customer_phone, vehicle_description)
VALUES (%s, %s, %s, %s, %s, 'por_revisar', %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id RETURNING id
""", ( """, (
data.get('tenant_id'), data.get('branch_id'), data.get('customer_id'), data.get('tenant_id'), data.get('branch_id'), data.get('customer_id'),
data.get('vehicle_id'), order_number, data.get('vehicle_id'), order_number,
data.get('priority', 'normal'), data.get('reception_notes'), data.get('priority', 'normal'), data.get('reception_notes'),
data.get('estimated_cost'), data.get('estimated_completion'), data.get('estimated_cost'), data.get('estimated_completion'),
data.get('employee_id'), data.get('mechanic_name'), data.get('employee_id'), data.get('mileage_in'),
data.get('mileage_in'), data.get('fuel_level'), data.get('created_by'), data.get('fuel_level'), data.get('created_by'),
data.get('delivery_method'), data.get('courier_id'),
data.get('is_direct', False), data.get('requires_invoice', False),
data.get('workshop_name'), data.get('customer_address'),
data.get('customer_phone'), data.get('vehicle_description'),
)) ))
so_id = cur.fetchone()[0] so_id = cur.fetchone()[0]
@@ -137,7 +73,7 @@ def create_service_order(conn, data):
cur.execute(""" cur.execute("""
INSERT INTO service_order_status_history INSERT INTO service_order_status_history
(service_order_id, new_status, changed_by, notes) (service_order_id, new_status, changed_by, notes)
VALUES (%s, 'por_revisar', %s, 'Orden creada') VALUES (%s, 'received', %s, 'Orden creada')
""", (so_id, data.get('created_by'))) """, (so_id, data.get('created_by')))
conn.commit() conn.commit()
@@ -150,28 +86,18 @@ def get_service_order(conn, so_id):
cur.execute(""" cur.execute("""
SELECT so.id, so.order_number, so.status, so.priority, SELECT so.id, so.order_number, so.status, so.priority,
so.customer_id, c.name as customer_name, c.phone as customer_phone, so.customer_id, c.name as customer_name, c.phone as customer_phone,
c.address as customer_address, c.price_tier as customer_price_tier,
so.vehicle_id, fv.plate as vehicle_plate, fv.make as vehicle_make, fv.model as vehicle_model, so.vehicle_id, fv.plate as vehicle_plate, fv.make as vehicle_make, fv.model as vehicle_model,
so.branch_id, b.name as branch_name, b.address as branch_address, b.phone as branch_phone, so.branch_id, so.reception_notes, so.diagnosis_notes, so.repair_notes,
so.reception_notes, so.diagnosis_notes, so.repair_notes,
so.delivery_notes, so.estimated_cost, so.final_cost, so.delivery_notes, so.estimated_cost, so.final_cost,
so.estimated_completion, so.actual_completion, so.delivered_at, so.estimated_completion, so.actual_completion, so.delivered_at,
so.mileage_in, so.mileage_out, so.fuel_level, so.mileage_in, so.mileage_out, so.fuel_level,
so.employee_id, e.name as employee_name, so.employee_id, e.name as employee_name,
so.mechanic_name, so.created_by, so.created_at, so.updated_at
so.created_by, creator.name as created_by_name,
so.created_at, so.updated_at,
so.delivery_method, so.courier_id, co.name as courier_name, so.is_direct,
so.requires_invoice, so.sale_id,
so.workshop_name, so.customer_address, so.customer_phone, so.vehicle_description
FROM service_orders so FROM service_orders so
LEFT JOIN customers c ON so.customer_id = c.id LEFT JOIN customers c ON so.customer_id = c.id
LEFT JOIN fleet_vehicles fv ON so.vehicle_id = fv.id LEFT JOIN fleet_vehicles fv ON so.vehicle_id = fv.id
LEFT JOIN employees e ON so.employee_id = e.id LEFT JOIN employees e ON so.employee_id = e.id
LEFT JOIN employees creator ON so.created_by = creator.id WHERE so.id = %s
LEFT JOIN branches b ON so.branch_id = b.id
LEFT JOIN couriers co ON so.courier_id = co.id
WHERE so.id = %s AND so.is_deleted = false
""", (so_id,)) """, (so_id,))
row = cur.fetchone() row = cur.fetchone()
if not row: if not row:
@@ -181,51 +107,35 @@ def get_service_order(conn, so_id):
so = { so = {
'id': row[0], 'order_number': row[1], 'status': row[2], 'priority': row[3], 'id': row[0], 'order_number': row[1], 'status': row[2], 'priority': row[3],
'customer_id': row[4], 'customer_name': row[5], 'customer_phone': row[6], 'customer_id': row[4], 'customer_name': row[5], 'customer_phone': row[6],
'customer_address': row[7], 'customer_price_tier': row[8], 'vehicle_id': row[7], 'vehicle_plate': row[8], 'vehicle_make': row[9], 'vehicle_model': row[10],
'vehicle_id': row[9], 'vehicle_plate': row[10], 'vehicle_make': row[11], 'vehicle_model': row[12], 'branch_id': row[11], 'reception_notes': row[12], 'diagnosis_notes': row[13],
'branch_id': row[13], 'branch_name': row[14], 'branch_address': row[15], 'branch_phone': row[16], 'repair_notes': row[14], 'delivery_notes': row[15],
'reception_notes': row[17], 'diagnosis_notes': row[18], 'estimated_cost': float(row[16]) if row[16] else None,
'repair_notes': row[19], 'delivery_notes': row[20], 'final_cost': float(row[17]) if row[17] else None,
'estimated_cost': float(row[21]) if row[21] else None, 'estimated_completion': str(row[18]) if row[18] else None,
'final_cost': float(row[22]) if row[22] else None, 'actual_completion': str(row[19]) if row[19] else None,
'estimated_completion': str(row[23]) if row[23] else None, 'delivered_at': str(row[20]) if row[20] else None,
'actual_completion': str(row[24]) if row[24] else None, 'mileage_in': row[21], 'mileage_out': row[22], 'fuel_level': row[23],
'delivered_at': str(row[25]) if row[25] else None, 'employee_id': row[24], 'employee_name': row[25],
'mileage_in': row[26], 'mileage_out': row[27], 'fuel_level': row[28], 'created_by': row[26], 'created_at': str(row[27]), 'updated_at': str(row[28]),
'employee_id': row[29], 'employee_name': row[30],
'mechanic_name': row[31],
'created_by': row[32], 'created_by_name': row[33],
'created_at': str(row[34]), 'updated_at': str(row[35]),
'delivery_method': row[36], 'courier_id': row[37], 'courier_name': row[38],
'is_direct': bool(row[39]) if row[39] is not None else False,
'requires_invoice': bool(row[40]) if row[40] is not None else False,
'sale_id': row[41],
'workshop_name': row[42], 'customer_address': row[43],
'customer_phone': row[44], 'vehicle_description': row[45],
} }
# Items # Items
cur.execute(""" cur.execute("""
SELECT id, inventory_id, part_number, name, quantity, unit_cost, unit_price, status, notes, SELECT id, inventory_id, part_number, name, quantity, unit_cost, unit_price, status, notes
mechanic_id, observations
FROM service_order_items FROM service_order_items
WHERE service_order_id = %s WHERE service_order_id = %s
ORDER BY id ORDER BY id
""", (so_id,)) """, (so_id,))
so['items'] = [] so['items'] = []
total_parts = 0.0
for r in cur.fetchall(): for r in cur.fetchall():
qty = float(r[4]) if r[4] else 0
price = float(r[6]) if r[6] else 0
so['items'].append({ so['items'].append({
'id': r[0], 'inventory_id': r[1], 'part_number': r[2], 'name': r[3], 'id': r[0], 'inventory_id': r[1], 'part_number': r[2], 'name': r[3],
'quantity': qty, 'quantity': float(r[4]) if r[4] else 0,
'unit_cost': float(r[5]) if r[5] else None, 'unit_cost': float(r[5]) if r[5] else None,
'unit_price': price, 'unit_price': float(r[6]) if r[6] else None,
'status': r[7], 'notes': r[8], 'status': r[7], 'notes': r[8],
'mechanic_id': r[9], 'observations': r[10],
}) })
total_parts += qty * price
# Labor # Labor
cur.execute(""" cur.execute("""
@@ -235,37 +145,27 @@ def get_service_order(conn, so_id):
ORDER BY id ORDER BY id
""", (so_id,)) """, (so_id,))
so['labor'] = [] so['labor'] = []
total_labor = 0.0
for r in cur.fetchall(): for r in cur.fetchall():
total = float(r[4]) if r[4] else 0
so['labor'].append({ so['labor'].append({
'id': r[0], 'description': r[1], 'id': r[0], 'description': r[1],
'hours': float(r[2]) if r[2] else 0, 'hours': float(r[2]) if r[2] else 0,
'hourly_rate': float(r[3]) if r[3] else 0, 'hourly_rate': float(r[3]) if r[3] else 0,
'total_cost': total, 'total_cost': float(r[4]) if r[4] else 0,
'employee_id': r[5], 'status': r[6], 'employee_id': r[5], 'status': r[6],
}) })
total_labor += total
so['total_parts'] = round(total_parts, 2)
so['total_labor'] = round(total_labor, 2)
so['total'] = round(total_parts + total_labor, 2)
# Status history # Status history
cur.execute(""" cur.execute("""
SELECT h.id, h.old_status, h.new_status, h.changed_by, e.name as changed_by_name, SELECT id, old_status, new_status, changed_by, notes, created_at
h.notes, h.created_at FROM service_order_status_history
FROM service_order_status_history h WHERE service_order_id = %s
LEFT JOIN employees e ON h.changed_by = e.id ORDER BY created_at
WHERE h.service_order_id = %s
ORDER BY h.created_at
""", (so_id,)) """, (so_id,))
so['status_history'] = [] so['status_history'] = []
for r in cur.fetchall(): for r in cur.fetchall():
so['status_history'].append({ so['status_history'].append({
'id': r[0], 'old_status': r[1], 'new_status': r[2], 'id': r[0], 'old_status': r[1], 'new_status': r[2],
'changed_by': r[3], 'changed_by_name': r[4], 'changed_by': r[3], 'notes': r[4], 'created_at': str(r[5]),
'notes': r[5], 'created_at': str(r[6]),
}) })
cur.close() cur.close()
@@ -273,8 +173,7 @@ def get_service_order(conn, so_id):
def list_service_orders(conn, status=None, branch_id=None, customer_id=None, def list_service_orders(conn, status=None, branch_id=None, customer_id=None,
priority=None, employee_id=None, delivery_method=None, priority=None, employee_id=None, page=1, per_page=50):
is_direct=None, q=None, page=1, per_page=50):
cur = conn.cursor() cur = conn.cursor()
where_clauses = [] where_clauses = []
params = [] params = []
@@ -294,49 +193,22 @@ def list_service_orders(conn, status=None, branch_id=None, customer_id=None,
if employee_id: if employee_id:
where_clauses.append("so.employee_id = %s") where_clauses.append("so.employee_id = %s")
params.append(employee_id) params.append(employee_id)
if delivery_method:
where_clauses.append("so.delivery_method = %s")
params.append(delivery_method)
if is_direct is not None:
where_clauses.append("so.is_direct = %s")
params.append(is_direct)
if q:
where_clauses.append(
"(so.order_number ILIKE %s OR c.name ILIKE %s OR fv.plate ILIKE %s OR "
"so.workshop_name ILIKE %s OR so.vehicle_description ILIKE %s)"
)
params.extend([f'%{q}%', f'%{q}%', f'%{q}%', f'%{q}%', f'%{q}%'])
where_clauses.append("so.is_deleted = false") where = " AND ".join(where_clauses) if where_clauses else "true"
where = " AND ".join(where_clauses)
cur.execute(f""" cur.execute(f"""
SELECT count(*) FROM service_orders so SELECT count(*) FROM service_orders so WHERE {where}
LEFT JOIN customers c ON so.customer_id = c.id
LEFT JOIN fleet_vehicles fv ON so.vehicle_id = fv.id
WHERE {where}
""", params) """, params)
total = cur.fetchone()[0] total = cur.fetchone()[0]
cur.execute(f""" cur.execute(f"""
SELECT so.id, so.order_number, so.status, so.priority, SELECT so.id, so.order_number, so.status, so.priority,
so.customer_id, c.name as customer_name, so.customer_id, c.name as customer_name,
so.vehicle_id, fv.plate as vehicle_plate, fv.make as vehicle_make, fv.model as vehicle_model, so.vehicle_id, fv.plate as vehicle_plate,
so.branch_id, b.name as branch_name, so.estimated_cost, so.estimated_completion, so.created_at
so.estimated_cost, so.final_cost,
so.delivery_method, co.name as courier_name, so.is_direct,
so.requires_invoice, so.sale_id, so.created_at,
creator.name as created_by_name,
so.employee_id, mech.name as employee_name,
so.mechanic_name,
so.workshop_name, so.vehicle_description
FROM service_orders so FROM service_orders so
LEFT JOIN customers c ON so.customer_id = c.id LEFT JOIN customers c ON so.customer_id = c.id
LEFT JOIN fleet_vehicles fv ON so.vehicle_id = fv.id LEFT JOIN fleet_vehicles fv ON so.vehicle_id = fv.id
LEFT JOIN branches b ON so.branch_id = b.id
LEFT JOIN couriers co ON so.courier_id = co.id
LEFT JOIN employees creator ON so.created_by = creator.id
LEFT JOIN employees mech ON so.employee_id = mech.id
WHERE {where} WHERE {where}
ORDER BY ORDER BY
CASE so.priority CASE so.priority
@@ -351,25 +223,13 @@ def list_service_orders(conn, status=None, branch_id=None, customer_id=None,
orders = [] orders = []
for r in cur.fetchall(): for r in cur.fetchall():
estimated = float(r[12]) if r[12] else 0
final = float(r[13]) if r[13] else None
orders.append({ orders.append({
'id': r[0], 'order_number': r[1], 'status': r[2], 'priority': r[3], 'id': r[0], 'order_number': r[1], 'status': r[2], 'priority': r[3],
'customer_id': r[4], 'customer_name': r[5], 'customer_id': r[4], 'customer_name': r[5],
'vehicle_id': r[6], 'vehicle_plate': r[7], 'vehicle_make': r[8], 'vehicle_model': r[9], 'vehicle_id': r[6], 'vehicle_plate': r[7],
'branch_id': r[10], 'branch_name': r[11], 'estimated_cost': float(r[8]) if r[8] else None,
'estimated_cost': estimated, 'estimated_completion': str(r[9]) if r[9] else None,
'final_cost': final, 'created_at': str(r[10]),
'delivery_method': r[14], 'courier_name': r[15],
'is_direct': bool(r[16]) if r[16] is not None else False,
'requires_invoice': bool(r[17]) if r[17] is not None else False,
'sale_id': r[18], 'created_at': str(r[19]),
'created_by_name': r[20],
'employee_id': r[21], 'employee_name': r[22],
'mechanic_name': r[23],
'workshop_name': r[24], 'vehicle_description': r[25],
'total': round(final or estimated, 2),
'paid': 0.0, # to be computed if needed
}) })
cur.close() cur.close()
@@ -383,7 +243,7 @@ def list_service_orders(conn, status=None, branch_id=None, customer_id=None,
def update_status(conn, so_id, new_status, changed_by=None, notes=None): def update_status(conn, so_id, new_status, changed_by=None, notes=None):
"""Update service order status with validation.""" """Update service order status with validation."""
cur = conn.cursor() cur = conn.cursor()
cur.execute("SELECT status FROM service_orders WHERE id = %s AND is_deleted = false", (so_id,)) cur.execute("SELECT status FROM service_orders WHERE id = %s", (so_id,))
row = cur.fetchone() row = cur.fetchone()
if not row: if not row:
cur.close() cur.close()
@@ -397,9 +257,9 @@ def update_status(conn, so_id, new_status, changed_by=None, notes=None):
# Update status # Update status
extra_sets = [] extra_sets = []
extra_vals = [] extra_vals = []
if new_status == 'reparada': if new_status == 'ready':
extra_sets.append("actual_completion = NOW()") extra_sets.append("actual_completion = NOW()")
if new_status == 'entregado': if new_status == 'delivered':
extra_sets.append("delivered_at = NOW()") extra_sets.append("delivered_at = NOW()")
extra_sets.append("delivered_by = %s") extra_sets.append("delivered_by = %s")
extra_vals.append(changed_by) extra_vals.append(changed_by)
@@ -428,16 +288,14 @@ def add_item(conn, so_id, item_data):
cur = conn.cursor() cur = conn.cursor()
cur.execute(""" cur.execute("""
INSERT INTO service_order_items INSERT INTO service_order_items
(service_order_id, inventory_id, part_number, name, quantity, unit_cost, unit_price, status, notes, (service_order_id, inventory_id, part_number, name, quantity, unit_cost, unit_price, status, notes)
mechanic_id, observations) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id RETURNING id
""", ( """, (
so_id, item_data.get('inventory_id'), item_data.get('part_number'), so_id, item_data.get('inventory_id'), item_data.get('part_number'),
item_data.get('name'), item_data.get('quantity', 1), item_data.get('name'), item_data.get('quantity', 1),
item_data.get('unit_cost'), item_data.get('unit_price'), item_data.get('unit_cost'), item_data.get('unit_price'),
item_data.get('status', 'pending'), item_data.get('notes'), item_data.get('status', 'pending'), item_data.get('notes'),
item_data.get('mechanic_id'), item_data.get('observations'),
)) ))
item_id = cur.fetchone()[0] item_id = cur.fetchone()[0]
conn.commit() conn.commit()
@@ -447,7 +305,7 @@ def add_item(conn, so_id, item_data):
def update_item(conn, item_id, data): def update_item(conn, item_id, data):
cur = conn.cursor() cur = conn.cursor()
allowed = ['part_number', 'name', 'quantity', 'unit_cost', 'unit_price', 'status', 'notes', 'mechanic_id', 'observations'] allowed = ['part_number', 'name', 'quantity', 'unit_cost', 'unit_price', 'status', 'notes']
sets = [] sets = []
vals = [] vals = []
for field in allowed: for field in allowed:
@@ -529,14 +387,10 @@ def remove_labor(conn, labor_id):
def update_service_order(conn, so_id, data): def update_service_order(conn, so_id, data):
"""Update general service order fields.""" """Update general service order fields."""
_normalize_delivery(data)
cur = conn.cursor() cur = conn.cursor()
allowed = ['customer_id', 'vehicle_id', 'branch_id', 'priority', allowed = ['priority', 'reception_notes', 'diagnosis_notes', 'repair_notes',
'reception_notes', 'diagnosis_notes', 'repair_notes',
'delivery_notes', 'estimated_cost', 'estimated_completion', 'delivery_notes', 'estimated_cost', 'estimated_completion',
'employee_id', 'mechanic_name', 'mileage_in', 'mileage_out', 'fuel_level', 'final_cost', 'employee_id', 'mileage_out', 'fuel_level', 'final_cost']
'delivery_method', 'courier_id', 'is_direct', 'requires_invoice',
'workshop_name', 'customer_address', 'customer_phone', 'vehicle_description']
sets = [] sets = []
vals = [] vals = []
for field in allowed: for field in allowed:
@@ -565,20 +419,19 @@ def get_kanban_summary(conn, branch_id=None):
cur.execute(f""" cur.execute(f"""
SELECT status, COUNT(*) as cnt SELECT status, COUNT(*) as cnt
FROM service_orders FROM service_orders
WHERE status != 'cancelled' AND is_deleted = false {branch_filter} WHERE status != 'cancelled' {branch_filter}
GROUP BY status GROUP BY status
""", params) """, params)
summary = {status: 0 for status in ORDER_STATUSES if status != 'cancelada'} summary = {status: 0 for status in VALID_TRANSITIONS if status != 'cancelled'}
for r in cur.fetchall(): for r in cur.fetchall():
summary[r[0]] = r[1] summary[r[0]] = r[1]
# Overdue orders (estimated_completion passed and not delivered/invoiced) # Overdue orders (estimated_completion passed and not ready/delivered)
cur.execute(f""" cur.execute(f"""
SELECT count(*) FROM service_orders SELECT count(*) FROM service_orders
WHERE estimated_completion < NOW() WHERE estimated_completion < NOW()
AND status NOT IN ('entregado', 'facturada', 'cancelada') AND status NOT IN ('ready', 'delivered', 'cancelled')
AND is_deleted = false
{branch_filter} {branch_filter}
""", params) """, params)
overdue = cur.fetchone()[0] overdue = cur.fetchone()[0]
@@ -601,7 +454,7 @@ def reserve_item(conn, so_item_id, branch_id, employee_id=None):
cur.execute( cur.execute(
""" """
SELECT soi.service_order_id, soi.inventory_id, soi.quantity, soi.status, SELECT soi.service_order_id, soi.inventory_id, soi.quantity, soi.status,
so.order_number, so.branch_id so.order_number
FROM service_order_items soi FROM service_order_items soi
JOIN service_orders so ON so.id = soi.service_order_id JOIN service_orders so ON so.id = soi.service_order_id
WHERE soi.id = %s WHERE soi.id = %s
@@ -613,7 +466,7 @@ def reserve_item(conn, so_item_id, branch_id, employee_id=None):
cur.close() cur.close()
raise ValueError("Service order item not found") raise ValueError("Service order item not found")
so_id, inventory_id, quantity, status, order_number, branch_id = row so_id, inventory_id, quantity, status, order_number = row
if status == "cancelled": if status == "cancelled":
cur.close() cur.close()
raise ValueError("Cannot reserve a cancelled item") raise ValueError("Cannot reserve a cancelled item")
@@ -622,11 +475,10 @@ def reserve_item(conn, so_item_id, branch_id, employee_id=None):
raise ValueError("Item has no inventory linked") raise ValueError("Item has no inventory linked")
qty = int(quantity) qty = int(quantity)
if not _tenant_allows_negative_stock(conn):
available = inventory_engine.get_stock(conn, inventory_id, branch_id) available = inventory_engine.get_stock(conn, inventory_id, branch_id)
if available < qty: if available < qty:
cur.close() cur.close()
raise ValueError(f"Sin stock suficiente. Disponible: {available}, solicitado: {qty}") raise ValueError(f"Insufficient stock. Available: {available}, requested: {qty}")
inventory_engine.record_operation( inventory_engine.record_operation(
conn, conn,
@@ -891,156 +743,10 @@ def convert_to_sale(conn, so_id, sale_data, employee_id=None):
return {"sale_id": sale_id, "total": total, "items_count": len(sale_items)} return {"sale_id": sale_id, "total": total, "items_count": len(sale_items)}
def convert_to_remission(conn, so_id, sale_data, employee_id=None):
"""Convert a service order into a counter remission note (pending payment).
sale_data keys:
register_id: int (optional)
notes: str (optional)
Returns dict with sale_id, total, items_count.
"""
cur = conn.cursor()
so = get_service_order(conn, so_id)
if not so:
cur.close()
raise ValueError("Service order not found")
if so["status"] == "cancelled":
cur.close()
raise ValueError("Cannot convert a cancelled service order")
if so.get("sale_id"):
cur.close()
raise ValueError("Service order already converted")
branch_id = so["branch_id"]
customer_id = so["customer_id"]
# Build sale items from SO parts and labor
sale_items = []
for item in so.get("items", []):
if item.get("status") == "cancelled":
continue
qty = int(item.get("quantity", 1))
unit_price = float(item.get("unit_price") or 0)
unit_cost = float(item.get("unit_cost") or 0)
sale_items.append({
"inventory_id": item.get("inventory_id"),
"part_number": item.get("part_number") or "PART",
"name": item.get("name") or "Refaccion",
"quantity": qty,
"unit_price": unit_price,
"unit_cost": unit_cost,
"tax_rate": 0.16,
})
for labor in so.get("labor", []):
if labor.get("status") == "cancelled":
continue
sale_items.append({
"inventory_id": None,
"part_number": "SERV",
"name": labor.get("description") or "Mano de obra",
"quantity": 1,
"unit_price": float(labor.get("total_cost") or 0),
"unit_cost": 0,
"tax_rate": 0.16,
})
if not sale_items:
cur.close()
raise ValueError("No items or labor to invoice")
subtotal = 0.0
tax_total = 0.0
for item in sale_items:
item_subtotal = item["quantity"] * item["unit_price"]
item_tax = item_subtotal * item["tax_rate"]
item["subtotal"] = item_subtotal
item["tax_amount"] = item_tax
subtotal += item_subtotal
tax_total += item_tax
total = subtotal + tax_total
register_id = sale_data.get("register_id")
notes = sale_data.get("notes") or f"Nota de remision desde orden {so['order_number']}"
cur.execute(
"""
INSERT INTO sales
(branch_id, customer_id, employee_id, register_id, sale_type,
payment_method, subtotal, discount_total, tax_total, total,
amount_paid, change_given, metodo_pago_sat, forma_pago_sat,
status, notes)
VALUES (%s, %s, %s, %s, 'counter_remission', 'remission', %s, %s, %s, %s, %s, %s, 'PPD', '99', 'pending_payment', %s)
RETURNING id, created_at
""",
(
branch_id,
customer_id,
employee_id,
register_id,
subtotal,
0,
tax_total,
total,
0,
0,
notes,
),
)
sale_id, _created_at = cur.fetchone()
for item in sale_items:
cur.execute(
"""
INSERT INTO sale_items
(sale_id, inventory_id, part_number, name, quantity,
unit_price, unit_cost, tax_rate, tax_amount, subtotal)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
sale_id,
item["inventory_id"],
item["part_number"],
item["name"],
item["quantity"],
item["unit_price"],
item["unit_cost"],
item["tax_rate"],
item["tax_amount"],
item["subtotal"],
),
)
# Reserve inventory for parts
for item in so.get("items", []):
if item.get("status") == "cancelled":
continue
inventory_id = item.get("inventory_id")
qty = int(item.get("quantity", 0))
if inventory_id and qty > 0:
inventory_engine.record_reservation(
conn,
inventory_id,
branch_id,
qty,
sale_id=sale_id,
cost_at_time=float(item.get("unit_cost") or 0),
notes=f"Reserva nota de remision orden {so['order_number']}"
)
# Link order to sale (the remission note)
cur.execute("UPDATE service_orders SET sale_id = %s WHERE id = %s", (sale_id, so_id))
conn.commit()
cur.close()
return {"sale_id": sale_id, "total": total, "items_count": len(sale_items)}
def assign_mechanic(conn, so_id, employee_id): def assign_mechanic(conn, so_id, employee_id):
"""Assign a mechanic/technician to a service order.""" """Assign a mechanic/technician to a service order."""
cur = conn.cursor() cur = conn.cursor()
cur.execute("SELECT id FROM service_orders WHERE id = %s AND is_deleted = false", (so_id,)) cur.execute("SELECT id FROM service_orders WHERE id = %s", (so_id,))
if not cur.fetchone(): if not cur.fetchone():
cur.close() cur.close()
raise ValueError("Service order not found") raise ValueError("Service order not found")
@@ -1054,21 +760,6 @@ def assign_mechanic(conn, so_id, employee_id):
return {"employee_id": employee_id} return {"employee_id": employee_id}
def delete_service_order(conn, so_id):
"""Soft-delete a service order."""
cur = conn.cursor()
cur.execute(
"UPDATE service_orders SET is_deleted = true WHERE id = %s AND is_deleted = false",
(so_id,),
)
deleted = cur.rowcount
conn.commit()
cur.close()
if deleted == 0:
raise ValueError("Service order not found")
return {"deleted": True}
# ─── Service catalog (reusable labor concepts) ─────────────────────────────── # ─── Service catalog (reusable labor concepts) ───────────────────────────────

View File

@@ -247,8 +247,7 @@ def provision_tenant(name, rfc=None, owner_name="Admin", owner_email=None, owner
'accounting.view', 'accounting.create', 'accounting.close', 'accounting.view', 'accounting.create', 'accounting.close',
'invoicing.view', 'invoicing.create', 'invoicing.cancel', 'invoicing.view', 'invoicing.create', 'invoicing.cancel',
'reports.view', 'reports.financial', 'reports.view', 'reports.financial',
'config.view', 'config.edit', 'config.edit_prices', 'config.view', 'config.edit', 'config.edit_prices'
'fleet.view', 'fleet.create', 'fleet.edit', 'fleet.delete'
] ]
tenant_cur.executemany( tenant_cur.executemany(
"INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s)", "INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s)",

View File

@@ -16,13 +16,9 @@ ALIGN_CENTER = ESC + b'a' + b'\x01'
ALIGN_RIGHT = ESC + b'a' + b'\x02' ALIGN_RIGHT = ESC + b'a' + b'\x02'
BOLD_ON = ESC + b'E' + b'\x01' BOLD_ON = ESC + b'E' + b'\x01'
BOLD_OFF = ESC + b'E' + b'\x00' BOLD_OFF = ESC + b'E' + b'\x00'
EMPH_ON = ESC + b'G' + b'\x01' # Double-strike / emphasized
EMPH_OFF = ESC + b'G' + b'\x00'
DOUBLE_HEIGHT = ESC + b'!' + b'\x10' DOUBLE_HEIGHT = ESC + b'!' + b'\x10'
NORMAL_SIZE = ESC + b'!' + b'\x00' NORMAL_SIZE = ESC + b'!' + b'\x00'
LARGE_SIZE = ESC + b'!' + b'\x30' # Double width + double height LARGE_SIZE = ESC + b'!' + b'\x30' # Double width + double height
LINE_SPACING_140 = ESC + b'3' + b'\x28' # ~1.4x line spacing
LINE_SPACING_DEFAULT = ESC + b'2'
def generate_ticket(sale_data, business_info, width=80): def generate_ticket(sale_data, business_info, width=80):
@@ -38,8 +34,6 @@ def generate_ticket(sale_data, business_info, width=80):
chars = 32 if width == 58 else 48 # characters per line chars = 32 if width == 58 else 48 # characters per line
buf = bytearray() buf = bytearray()
buf += INIT buf += INIT
buf += EMPH_ON
buf += LINE_SPACING_140
# Header: business name (centered, bold, large) # Header: business name (centered, bold, large)
buf += ALIGN_CENTER buf += ALIGN_CENTER
@@ -106,10 +100,7 @@ def generate_ticket(sale_data, business_info, width=80):
buf += 'Gracias por su compra!\n'.encode('cp437', errors='replace') buf += 'Gracias por su compra!\n'.encode('cp437', errors='replace')
buf += 'Nexus Autoparts POS\n'.encode('cp437', errors='replace') buf += 'Nexus Autoparts POS\n'.encode('cp437', errors='replace')
buf += b'\n\n\n' buf += b'\n\n\n'
buf += LINE_SPACING_DEFAULT buf += PARTIAL_CUT
buf += EMPH_OFF
buf += FEED + b'\x05' # Feed 5 lines before cutting
buf += CUT # Full cut
return bytes(buf) return bytes(buf)
@@ -128,8 +119,6 @@ def generate_quotation_ticket(quote_data, business_info, width=80):
chars = 32 if width == 58 else 48 chars = 32 if width == 58 else 48
buf = bytearray() buf = bytearray()
buf += INIT buf += INIT
buf += EMPH_ON
buf += LINE_SPACING_140
# Header # Header
buf += ALIGN_CENTER buf += ALIGN_CENTER
@@ -198,10 +187,7 @@ def generate_quotation_ticket(quote_data, business_info, width=80):
buf += 'Precios sujetos a disponibilidad\n'.encode('cp437', errors='replace') buf += 'Precios sujetos a disponibilidad\n'.encode('cp437', errors='replace')
buf += 'Nexus Autoparts POS\n'.encode('cp437', errors='replace') buf += 'Nexus Autoparts POS\n'.encode('cp437', errors='replace')
buf += b'\n\n\n' buf += b'\n\n\n'
buf += LINE_SPACING_DEFAULT buf += PARTIAL_CUT
buf += EMPH_OFF
buf += FEED + b'\x05'
buf += CUT
return bytes(buf) return bytes(buf)
@@ -238,8 +224,6 @@ def generate_service_order_ticket(so_data, business_info, width=80):
chars = 32 if width == 58 else 48 chars = 32 if width == 58 else 48
buf = bytearray() buf = bytearray()
buf += INIT buf += INIT
buf += EMPH_ON
buf += LINE_SPACING_140
# Header # Header
buf += ALIGN_CENTER buf += ALIGN_CENTER
@@ -354,9 +338,6 @@ def generate_service_order_ticket(so_data, business_info, width=80):
buf += "No es comprobante fiscal\n".encode("cp437", errors="replace") buf += "No es comprobante fiscal\n".encode("cp437", errors="replace")
buf += "Nexus Autoparts POS\n".encode("cp437", errors="replace") buf += "Nexus Autoparts POS\n".encode("cp437", errors="replace")
buf += b"\n\n\n" buf += b"\n\n\n"
buf += LINE_SPACING_DEFAULT buf += PARTIAL_CUT
buf += EMPH_OFF
buf += FEED + b'\x05'
buf += CUT
return bytes(buf) return bytes(buf)

View File

@@ -1243,45 +1243,3 @@
.summary-strip { grid-template-columns: 1fr; } .summary-strip { grid-template-columns: 1fr; }
.finance-grid { grid-template-columns: 1fr; } .finance-grid { grid-template-columns: 1fr; }
} }
/* =========================================================================
MODAL DETALLE CUENTA POR COBRAR
========================================================================= */
.modal-overlay {
position: fixed; inset: 0;
background: var(--overlay-backdrop);
display: flex; align-items: center; justify-content: center;
z-index: var(--z-modal);
opacity: 0; pointer-events: none;
transition: var(--transition-normal);
}
.modal-overlay.open { opacity: 1; pointer-events: auto; }
.modal-pago {
background: var(--color-bg-elevated); border: 1px solid var(--color-border);
border-radius: var(--radius-lg); box-shadow: var(--shadow-xl);
width: 560px; max-width: 95vw; max-height: 90vh; overflow-y: auto;
transform: translateY(20px); transition: var(--transition-normal);
}
.modal-overlay.open .modal-pago { transform: translateY(0); }
.modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: var(--space-5) var(--space-6); border-bottom: 1px solid var(--color-border);
}
.modal-header h3 {
font-family: var(--font-heading); font-size: var(--text-h4);
font-weight: var(--heading-weight-primary); color: var(--color-text-primary);
}
.modal-close {
width: 36px; height: 36px; display: flex; align-items: center; justify-content: center;
background: transparent; border: 1px solid var(--color-border);
border-radius: var(--radius-md); cursor: pointer;
color: var(--color-text-muted); font-size: 18px; transition: var(--transition-fast);
}
.modal-close:hover { background: var(--color-surface-2); color: var(--color-text-primary); }
.modal-footer {
display: flex; align-items: center; justify-content: flex-end; gap: var(--space-3);
padding: var(--space-4) var(--space-6); border-top: 1px solid var(--color-border);
}

View File

@@ -1186,8 +1186,6 @@
.badge--green { background: rgba(34,197,94,0.15); color: var(--color-success); } .badge--green { background: rgba(34,197,94,0.15); color: var(--color-success); }
.badge--yellow { background: rgba(234,179,8,0.15); color: #eab308; } .badge--yellow { background: rgba(234,179,8,0.15); color: #eab308; }
.badge--purple { background: rgba(168,85,247,0.15); color: #a855f7; } .badge--purple { background: rgba(168,85,247,0.15); color: #a855f7; }
.badge--orange { background: rgba(249,115,22,0.15); color: #f97316; }
.badge--teal { background: rgba(20,184,166,0.15); color: #14b8a6; }
/* Toast notification */ /* Toast notification */
.cfg-toast { .cfg-toast {

View File

@@ -1209,16 +1209,6 @@
color: #000; color: #000;
} }
.action-btn--danger {
color: var(--color-error);
border-color: var(--color-error);
}
.action-btn--danger:hover {
background-color: var(--color-error);
color: #fff;
}
.action-btn__icon { .action-btn__icon {
width: 20px; width: 20px;
height: 20px; height: 20px;

View File

@@ -117,16 +117,6 @@
.empty-state__action { .empty-state__action {
margin-top: var(--space-2, 0.5rem); margin-top: var(--space-2, 0.5rem);
} }
.empty-state__icon svg {
display: block;
}
.empty-state__icon[aria-hidden="true"],
.empty-state__icon .empty-state__spinner {
animation: empty-spin 1s linear infinite;
}
@keyframes empty-spin {
to { transform: rotate(360deg); }
}
/* ═══════════════════════════════════════════════════════════════ /* ═══════════════════════════════════════════════════════════════
4. UNIFIED INPUT FOCUS RING 4. UNIFIED INPUT FOCUS RING

View File

@@ -713,23 +713,18 @@
} }
.pago-tabs { .pago-tabs {
display: grid; display: flex; border-bottom: 2px solid var(--color-border); padding: 0 var(--space-6);
grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
gap: var(--space-2);
padding: var(--space-4) var(--space-6);
border-bottom: none;
} }
.pago-tab { .pago-tab {
padding: var(--space-3) var(--space-2); font-family: var(--font-body); padding: var(--space-3) var(--space-5); font-family: var(--font-body);
font-size: var(--text-caption); font-weight: var(--font-weight-semibold); font-size: var(--text-body-sm); font-weight: var(--font-weight-semibold);
background: var(--color-surface); border: 1px solid var(--color-border); background: transparent; border: none; color: var(--color-text-muted);
border-radius: var(--radius-md); color: var(--color-text-muted); cursor: pointer; border-bottom: 2px solid transparent;
cursor: pointer; transition: var(--transition-fast); margin-bottom: -2px; transition: var(--transition-fast);
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; gap: var(--space-2);
text-align: center; min-height: 44px;
} }
.pago-tab:hover { background: var(--color-surface-2); color: var(--color-text-primary); } .pago-tab:hover { color: var(--color-text-primary); }
.pago-tab.active { background: var(--color-primary); border-color: var(--color-primary); color: #fff; } .pago-tab.active { color: var(--color-text-accent); border-bottom-color: var(--color-primary); }
.tab-content { padding: var(--space-6); display: none; } .tab-content { padding: var(--space-6); display: none; }
.tab-content.active { display: block; } .tab-content.active { display: block; }
@@ -906,10 +901,8 @@
font-family: 'Courier New', 'Consolas', monospace; font-family: 'Courier New', 'Consolas', monospace;
font-size: 11px; line-height: 1.4; padding: 12px; text-align: left; font-size: 11px; line-height: 1.4; padding: 12px; text-align: left;
border: 1px dashed #ccc; border: 1px dashed #ccc;
box-sizing: border-box;
} }
.ticket-80 { width: 80mm; max-width: 80mm; } .ticket-80 { width: 302px; }
.ticket-58 { width: 48mm; max-width: 48mm; }
.ticket .store-name { font-size: 14px; font-weight: bold; text-align: center; margin-bottom: 2px; } .ticket .store-name { font-size: 14px; font-weight: bold; text-align: center; margin-bottom: 2px; }
.ticket .store-tagline { font-size: 9px; text-align: center; color: #555; margin-bottom: 4px; } .ticket .store-tagline { font-size: 9px; text-align: center; color: #555; margin-bottom: 4px; }
.ticket .store-info { font-size: 9px; text-align: center; color: #333; margin-bottom: 6px; line-height: 1.3; } .ticket .store-info { font-size: 9px; text-align: center; color: #333; margin-bottom: 6px; line-height: 1.3; }
@@ -928,26 +921,17 @@
gap: 8px; align-items: baseline; font-size: 10px; margin-bottom: 3px; gap: 8px; align-items: baseline; font-size: 10px; margin-bottom: 3px;
} }
.ticket-80 .item-line-wide .qty { font-weight: bold; min-width: 24px; text-align: right; } .ticket-80 .item-line-wide .qty { font-weight: bold; min-width: 24px; text-align: right; }
.ticket-80 .item-line-wide .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; word-break: break-word; } .ticket-80 .item-line-wide .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ticket-80 .item-line-wide .price { text-align: right; min-width: 55px; } .ticket-80 .item-line-wide .price { text-align: right; min-width: 55px; }
.ticket-80 .item-line-wide .subtotal { text-align: right; font-weight: bold; min-width: 60px; } .ticket-80 .item-line-wide .subtotal { text-align: right; font-weight: bold; min-width: 60px; }
.ticket-line { page-break-inside: avoid; }
@media print { @media print {
@page { margin: 0; size: 72mm auto; } body * { display: none !important; }
html, body { height: auto !important; overflow: visible !important; } .ticket-print-area, .ticket-print-area * { display: block !important; }
body > * { display: none !important; } .ticket-print-area { position: fixed; top: 0; left: 0; }
.ticket-print-area, .ticket { border: none; box-shadow: none; padding: 4px; }
.ticket-print-area * { display: block !important; } .ticket .item-line-wide { display: grid !important; }
.ticket-print-area {
position: static !important; width: 72mm !important; margin: 0 !important; padding: 0 !important;
}
.ticket, .ticket-80 { width: 72mm !important; max-width: 72mm !important; border: none !important; box-shadow: none !important; padding: 3mm !important; font-size: 9pt !important; }
.ticket-58 { width: 48mm !important; max-width: 48mm !important; }
.ticket * { word-break: break-word !important; }
.ticket .item-line-wide { display: grid !important; grid-template-columns: auto 1fr auto auto !important; gap: 2mm !important; font-size: 8pt !important; }
.ticket .ticket-row, .ticket .folio-line, .ticket .total-line { display: flex !important; } .ticket .ticket-row, .ticket .folio-line, .ticket .total-line { display: flex !important; }
.ticket .name { white-space: normal !important; }
} }
/* ===================================================================== /* =====================================================================

View File

@@ -1,195 +0,0 @@
/* Remission notes page — matches the Nexus POS design system */
/* Scrollable page content area */
.page-content {
flex: 1;
overflow-y: auto;
padding: var(--space-5) var(--space-6);
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}
.page-content::-webkit-scrollbar { width: 6px; }
.page-content::-webkit-scrollbar-track { background: var(--scrollbar-track); }
.page-content::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: var(--radius-full); }
/* Filters card */
.filters-card {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-4);
margin-bottom: var(--space-5);
box-shadow: var(--shadow-sm);
}
[data-theme="modern"] .filters-card {
background: var(--color-bg-overlay);
}
/* Date input reuse select-filter styling */
.select-filter[type="date"] {
padding: 0 var(--space-3);
}
/* Status badges for remission notes */
.badge--pending_payment { background: var(--color-primary-muted); color: var(--color-primary); }
.badge--completed { background: rgba(34, 197, 94, 0.15); color: var(--color-success); }
.badge--cancelled { background: rgba(115, 115, 115, 0.15); color: var(--color-text-muted); }
/* Action buttons in table */
.action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-1);
padding: 0 var(--space-2);
height: 28px;
border-radius: var(--radius-md);
font-size: var(--text-caption);
font-weight: var(--font-weight-semibold);
cursor: pointer;
border: 1px solid transparent;
transition: var(--transition-fast);
white-space: nowrap;
}
.action-btn svg {
width: 13px;
height: 13px;
stroke: currentColor;
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.action-btn--ghost {
background: var(--btn-ghost-bg);
color: var(--btn-ghost-text);
border-color: var(--btn-ghost-border);
}
.action-btn--ghost:hover {
background: var(--color-surface-2);
border-color: var(--color-border-strong);
color: var(--color-text-primary);
}
.action-btn--primary {
background: var(--btn-primary-bg);
color: var(--btn-primary-text);
border-color: var(--btn-primary-border);
}
.action-btn--primary:hover { background: var(--btn-primary-bg-hover); }
/* Empty state */
.empty-state {
padding: var(--space-10) var(--space-6);
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: none;
align-items: center;
justify-content: center;
z-index: var(--z-modal, 1050);
padding: var(--space-4);
}
.modal-overlay.is-open { display: flex; }
.modal {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
width: 100%;
max-width: 420px;
max-height: 90vh;
overflow-y: auto;
box-shadow: var(--shadow-xl);
display: flex;
flex-direction: column;
}
[data-theme="modern"] .modal {
background: var(--color-bg-overlay);
}
.modal__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--color-border);
}
.modal__title {
font-family: var(--font-heading);
font-size: var(--text-h6);
font-weight: var(--heading-weight-primary);
color: var(--color-text-primary);
margin: 0;
}
.modal__close {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 1.25rem;
cursor: pointer;
line-height: 1;
padding: var(--space-1);
}
.modal__close:hover { color: var(--color-text-primary); }
.modal__body {
padding: var(--space-5);
}
.modal__footer {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
padding: var(--space-4) var(--space-5);
border-top: 1px solid var(--color-border);
}
/* Ticket preview (monospace) */
.ticket-preview {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.8125rem;
line-height: 1.45;
color: var(--color-text-primary);
}
.ticket-preview__center { text-align: center; }
.ticket-preview__bold { font-weight: 700; }
.ticket-preview__line {
display: flex;
justify-content: space-between;
gap: var(--space-3);
}
.ticket-preview__divider {
border-top: 1px dashed var(--color-border-strong);
margin: var(--space-3) 0;
}
.ticket-preview__items { margin: var(--space-3) 0; }
.ticket-preview__item {
display: flex;
justify-content: space-between;
gap: var(--space-3);
}
.ticket-preview__footer {
text-align: center;
margin-top: var(--space-3);
}
/* Responsive */
@media (max-width: 1024px) {
.page-content {
padding: var(--space-4);
}
.toolbar {
flex-direction: column;
align-items: stretch;
}
.search-box, .select-filter {
max-width: 100%;
width: 100%;
}
.toolbar__spacer { display: none; }
}

View File

@@ -767,257 +767,3 @@ body {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
/* ═══════════════════════════════════════════════════════════════
LIST VIEW, FILTERS & TABS
═══════════════════════════════════════════════════════════════ */
.workshop-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-4) var(--space-6);
background: var(--color-bg-elevated);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
flex-wrap: wrap;
}
[data-theme="industrial"] .workshop-toolbar {
background: var(--color-surface-1);
}
.workshop-filters {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.workshop-filters .form-input {
min-width: 160px;
width: auto;
}
.toolbar-toggle {
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--color-text-primary);
font-size: var(--text-body-sm);
cursor: pointer;
user-select: none;
}
.toolbar-toggle input {
width: 16px;
height: 16px;
accent-color: var(--color-primary);
}
.view-switch {
display: inline-flex;
background: var(--color-bg-base);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
}
.view-switch__btn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background: transparent;
border: none;
color: var(--color-text-muted);
font-size: var(--text-body-sm);
cursor: pointer;
transition: var(--transition-fast);
}
.view-switch__btn svg {
width: 16px;
height: 16px;
stroke: currentColor;
fill: none;
stroke-width: 2;
}
.view-switch__btn.is-active {
background: var(--color-primary);
color: #fff;
}
.workshop-list {
flex: 1;
overflow: auto;
padding: var(--space-4) var(--space-6);
}
.workshop-table {
width: 100%;
}
.workshop-table th,
.workshop-table td {
white-space: nowrap;
}
.workshop-table td:nth-child(2) {
color: var(--color-primary);
}
.list-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-4);
margin-top: var(--space-4);
}
.pagination-info {
font-size: var(--text-body-sm);
color: var(--color-text-muted);
}
/* ── Detail tabs ── */
.so-detail-header {
display: grid;
gap: var(--space-1);
margin-bottom: var(--space-4);
padding-bottom: var(--space-4);
border-bottom: 1px solid var(--color-border);
}
.so-detail-header__row {
font-size: var(--text-body-sm);
}
.so-detail-info {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.so-tabs {
display: flex;
gap: var(--space-1);
border-bottom: 1px solid var(--color-border);
margin-bottom: var(--space-4);
}
.so-tabs__btn {
padding: var(--space-2) var(--space-4);
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--color-text-muted);
font-size: var(--text-body);
cursor: pointer;
transition: var(--transition-fast);
}
.so-tabs__btn.is-active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
.so-tab-panel {
animation: fadeIn 0.2s ease;
}
.bitacora-table {
width: 100%;
}
/* ═══════════════════════════════════════════════════════════════
BADGE STATUS COLORS
═══════════════════════════════════════════════════════════════ */
.badge--received { background: rgba(59, 130, 246, 0.12); color: #3b82f6; }
.badge--diagnosis { background: rgba(99, 102, 241, 0.12); color: #6366f1; }
.badge--waiting_parts { background: rgba(245, 166, 35, 0.12); color: #f5a623; }
.badge--repair { background: rgba(245, 166, 35, 0.18); color: #d97706; }
.badge--quality_check { background: rgba(139, 92, 246, 0.12); color: #8b5cf6; }
.badge--ready { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
.badge--delivered { background: rgba(16, 185, 129, 0.12); color: #10b981; }
.badge--cancelled { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
.badge--pending { background: rgba(148, 163, 184, 0.12); color: #94a3b8; }
.badge--por_revisar { background: rgba(59, 130, 246, 0.12); color: #3b82f6; }
.badge--en_revision { background: rgba(99, 102, 241, 0.12); color: #6366f1; }
.badge--revisada { background: rgba(139, 92, 246, 0.12); color: #8b5cf6; }
.badge--cotizada { background: rgba(245, 166, 35, 0.12); color: #f5a623; }
.badge--por_autorizar { background: rgba(249, 115, 22, 0.12); color: #f97316; }
.badge--autorizada { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
.badge--autorizacion_parcial { background: rgba(16, 185, 129, 0.12); color: #10b981; }
.badge--en_reparacion { background: rgba(245, 166, 35, 0.18); color: #d97706; }
.badge--reparada { background: rgba(20, 184, 166, 0.12); color: #14b8a6; }
.badge--por_entregar { background: rgba(59, 130, 246, 0.16); color: #2563eb; }
.badge--entregado { background: rgba(16, 185, 129, 0.12); color: #10b981; }
.badge--por_enviar { background: rgba(99, 102, 241, 0.16); color: #4f46e5; }
.badge--enviado { background: rgba(14, 165, 233, 0.12); color: #0ea5e9; }
.badge--por_facturar { background: rgba(168, 85, 247, 0.12); color: #a855f7; }
.badge--facturada { background: rgba(236, 72, 153, 0.12); color: #ec4899; }
.badge--por_recolectar { background: rgba(100, 116, 139, 0.12); color: #64748b; }
.badge--cancelada { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
.badge--revisando { background: rgba(99, 102, 241, 0.12); color: #6366f1; }
.badge--revisado { background: rgba(139, 92, 246, 0.12); color: #8b5cf6; }
.badge--cotizado { background: rgba(245, 166, 35, 0.12); color: #f5a623; }
.badge--por_autorizar { background: rgba(249, 115, 22, 0.12); color: #f97316; }
.badge--autorizado { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
.badge--reparado { background: rgba(20, 184, 166, 0.12); color: #14b8a6; }
.badge--cancelado { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
.badge--normal { background: rgba(148, 163, 184, 0.12); color: #94a3b8; }
.badge--high { background: rgba(245, 166, 35, 0.12); color: #f5a623; }
.badge--urgent { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
/* ═══════════════════════════════════════════════════════════════
MODAL FOOTER
═══════════════════════════════════════════════════════════════ */
.modal__footer {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
flex-wrap: wrap;
}
.modal__footer .so-detail__actions {
margin-right: auto;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 1024px) {
.so-detail-info {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.workshop-toolbar {
flex-direction: column;
align-items: stretch;
}
.workshop-filters {
flex-direction: column;
align-items: stretch;
}
.workshop-filters .form-input {
width: 100%;
}
.so-detail-info {
grid-template-columns: 1fr;
}
}

View File

@@ -1,9 +1,9 @@
// /home/Autopartes/pos/static/js/accounting.js
// Accounting module — wired to design-system HTML IDs // Accounting module — wired to design-system HTML IDs
// Tabs: panel-cxc, panel-cxp, panel-balance, panel-resultados, panel-flujo, panel-conciliacion, panel-cierre // Tabs: panel-cxc, panel-cxp, panel-balance, panel-resultados, panel-flujo, panel-conciliacion, panel-cierre
const Accounting = (() => { const Accounting = (() => {
const API = '/pos/api/accounting'; const API = '/pos/api/accounting';
let accountsList = [];
function token() { function token() {
return localStorage.getItem('pos_token') || ''; return localStorage.getItem('pos_token') || '';
@@ -14,8 +14,7 @@ const Accounting = (() => {
} }
async function api(path, opts = {}) { async function api(path, opts = {}) {
const url = path.startsWith('/pos/api/') ? path : `${API}${path}`; const res = await fetch(`${API}${path}`, { headers: headers(), ...opts });
const res = await fetch(url, { headers: headers(), ...opts });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText })); const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || 'Request failed'); throw new Error(err.error || 'Request failed');
@@ -26,12 +25,6 @@ const Accounting = (() => {
function fmt(n) { function fmt(n) {
return parseFloat(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return parseFloat(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
} }
function esc(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// ---- Auth check ---- // ---- Auth check ----
function checkAuth() { function checkAuth() {
@@ -42,30 +35,6 @@ const Accounting = (() => {
return true; return true;
} }
async function loadChartOfAccounts() {
if (accountsList.length) return;
try {
const res = await api('/accounts');
accountsList = (res.data || []).filter(a => a.is_active);
populateEntryAccountSelects();
} catch (e) {
console.warn('No se pudieron cargar cuentas contables:', e);
}
}
function accountSelectHtml(selectedId) {
if (!accountsList.length) {
return '<select class="entry-account" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);"><option value="">Cargando cuentas...</option></select>';
}
let html = '<select class="entry-account" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);"><option value="">Selecciona cuenta</option>';
accountsList.forEach(a => {
const selected = selectedId && String(selectedId) === String(a.id) ? ' selected' : '';
html += `<option value="${a.id}"${selected}>${esc(a.code)} - ${esc(a.name)}</option>`;
});
html += '</select>';
return html;
}
// ---- Tab switching (matches design system onclick="switchTab('xxx')") ---- // ---- Tab switching (matches design system onclick="switchTab('xxx')") ----
function switchTab(name) { function switchTab(name) {
document.querySelectorAll('.tab-btn').forEach(btn => { document.querySelectorAll('.tab-btn').forEach(btn => {
@@ -121,36 +90,25 @@ const Accounting = (() => {
try { try {
const res = await api('/aging'); const res = await api('/aging');
let rows = res.data || []; const rows = res.data || [];
const statusFilter = document.getElementById('cxc-status-filter');
const selected = statusFilter ? statusFilter.value : 'all';
if (selected !== 'all') {
rows = rows.filter(r => {
const status = r.status || (r.days_overdue > 0 ? 'overdue' : r.paid > 0 && r.balance > 0 ? 'partial' : r.balance <= 0 ? 'ok' : 'pending');
return status === selected;
});
}
if (!rows.length) { if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">No hay cuentas por cobrar.</td></tr>'; tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">No hay cuentas por cobrar.</td></tr>';
return; return;
} }
tbody.innerHTML = rows.map(r => { tbody.innerHTML = rows.map(r => {
const status = r.status || (r.days_overdue > 0 ? 'overdue' : r.paid > 0 && r.balance > 0 ? 'partial' : r.balance <= 0 ? 'ok' : 'pending'); const status = r.days_overdue > 0 ? 'overdue' : r.paid > 0 && r.balance > 0 ? 'partial' : r.balance <= 0 ? 'ok' : 'pending';
const label = r.status_label || (status === 'overdue' ? 'Vencida' : status === 'partial' ? 'Parcial' : status === 'ok' ? 'Pagada' : 'Vigente'); const label = status === 'overdue' ? 'Vencida' : status === 'partial' ? 'Parcial' : status === 'ok' ? 'Pagada' : 'Vigente';
const actionLabel = r.balance > 0 ? 'Cobrar' : 'Ver';
return `<tr> return `<tr>
<td class="td--mono">${r.invoice || r.folio || '-'}</td> <td class="td--mono">${r.invoice || r.folio || '-'}</td>
<td class="td--primary">${r.customer_name || r.name || '-'}</td> <td class="td--primary">${r.name || r.customer_name || '-'}</td>
<td>${r.issue_date ? new Date(r.issue_date).toLocaleDateString('es-MX') : '-'}</td> <td>${r.issue_date ? new Date(r.issue_date).toLocaleDateString('es-MX') : '-'}</td>
<td>${r.due_date ? new Date(r.due_date).toLocaleDateString('es-MX') : '-'}</td> <td>${r.due_date ? new Date(r.due_date).toLocaleDateString('es-MX') : '-'}</td>
<td class="td--amount">$${fmt(r.total)}</td> <td class="td--amount">$${fmt(r.total)}</td>
<td class="td--amount">$${fmt(r.paid || 0)}</td> <td class="td--amount">$${fmt(r.paid || 0)}</td>
<td class="td--amount">$${fmt(r.balance || r.total)}</td> <td class="td--amount">$${fmt(r.balance || r.total)}</td>
<td>${statusBadge(status, label)}</td> <td>${statusBadge(status, label)}</td>
<td><button class="btn btn--ghost btn--sm" onclick="Accounting.showReceivableDetail(${r.sale_id})">${esc(actionLabel)}</button></td> <td><button class="btn btn--ghost btn--sm">${r.balance > 0 ? 'Cobrar' : 'Ver'}</button></td>
</tr>`; </tr>`;
}).join(''); }).join('');
@@ -165,90 +123,6 @@ const Accounting = (() => {
} }
} }
// ---- Receivable detail / cancel ticket ----
async function showReceivableDetail(saleId) {
try {
const sale = await api('/pos/api/sales/' + saleId);
if (!sale || sale.error) {
alert('No se pudo cargar el detalle de la venta');
return;
}
const itemsHtml = (sale.items || []).map(function (item) {
return '<tr>' +
'<td>' + esc(item.part_number || '-') + '</td>' +
'<td>' + esc(item.name) + '</td>' +
'<td style="text-align:right">' + item.quantity + '</td>' +
'<td style="text-align:right">$' + fmt(item.unit_price) + '</td>' +
'<td style="text-align:right">$' + fmt(item.subtotal) + '</td>' +
'</tr>';
}).join('');
const paid = (sale.payments || []).reduce(function (sum, p) { return sum + (p.amount || 0); }, 0) + (sale.amount_paid || 0);
const balance = (sale.total || 0) - paid;
const canCancel = sale.status !== 'cancelled' && balance > 0;
const html = '<div class="modal-overlay open" id="receivableDetailOverlay" style="z-index:2000;">' +
'<div class="modal-pago" style="max-width:600px;width:90%;max-height:90vh;overflow:auto;">' +
'<div class="modal-header"><h3>Detalle de Venta a Crédito</h3>' +
'<button class="modal-close" onclick="Accounting.closeReceivableDetail()">&#x2715;</button></div>' +
'<div style="padding:var(--space-4);">' +
'<p><strong>Ticket:</strong> VTA-' + sale.id + '</p>' +
'<p><strong>Cliente:</strong> ' + esc(sale.customer_name || '-') + '</p>' +
'<p><strong>Fecha:</strong> ' + (sale.created_at ? new Date(sale.created_at).toLocaleString('es-MX') : '-') + '</p>' +
'<p><strong>Estado:</strong> ' + esc(sale.status) + '</p>' +
'<p><strong>Total:</strong> $' + fmt(sale.total) + '</p>' +
'<p><strong>Pagado:</strong> $' + fmt(paid) + '</p>' +
'<p><strong>Saldo:</strong> $' + fmt(balance) + '</p>' +
'<h4 style="margin-top:var(--space-4);margin-bottom:var(--space-2);">Artículos</h4>' +
'<table class="data-table"><thead><tr><th>Clave</th><th>Producto</th><th>Cant</th><th>P.Unit</th><th>Subtotal</th></tr></thead><tbody>' +
(itemsHtml || '<tr><td colspan="5" style="text-align:center;">Sin artículos</td></tr>') +
'</tbody></table>' +
'</div>' +
'<div class="modal-footer">' +
'<button class="btn btn-ghost" onclick="Accounting.closeReceivableDetail()">Cerrar</button>' +
(canCancel ? '<button class="btn btn-danger" onclick="Accounting.cancelReceivable(' + sale.id + ')">Cancelar Ticket</button>' : '') +
'</div>' +
'</div></div>';
const existing = document.getElementById('receivableDetailOverlay');
if (existing) existing.remove();
document.body.insertAdjacentHTML('beforeend', html);
} catch (e) {
alert('Error al cargar detalle: ' + e.message);
}
}
function closeReceivableDetail() {
const el = document.getElementById('receivableDetailOverlay');
if (el) el.remove();
}
async function cancelReceivable(saleId) {
const reason = prompt('Motivo de cancelación del ticket (mínimo 3 caracteres):');
if (!reason || reason.trim().length < 3) {
alert('Se requiere un motivo para cancelar.');
return;
}
if (!confirm('¿Estás seguro de cancelar el ticket VTA-' + saleId + '? Esta acción reversa el inventario y el crédito del cliente.')) {
return;
}
try {
const res = await api('/pos/api/sales/' + saleId + '/cancel', {
method: 'PUT',
body: JSON.stringify({ reason: reason.trim() })
});
if (res.error) {
alert('Error: ' + res.error);
return;
}
alert('Ticket cancelado correctamente.');
closeReceivableDetail();
loadAging();
} catch (e) {
alert('Error al cancelar: ' + e.message);
}
}
// ---- Tab 2: Cuentas por Pagar ---- // ---- Tab 2: Cuentas por Pagar ----
async function loadAccountsPayable() { async function loadAccountsPayable() {
const panel = document.getElementById('panel-cxp'); const panel = document.getElementById('panel-cxp');
@@ -259,35 +133,25 @@ const Accounting = (() => {
try { try {
// Use accounts endpoint filtered for payables or a dedicated endpoint // Use accounts endpoint filtered for payables or a dedicated endpoint
const res = await api('/aging?type=payable'); const res = await api('/aging?type=payable');
let rows = res.data || []; const rows = res.data || [];
const statusFilter = document.getElementById('cxp-status-filter');
const selected = statusFilter ? statusFilter.value : 'all';
if (selected !== 'all') {
rows = rows.filter(r => {
const status = r.status || (r.days_overdue > 0 ? 'overdue' : r.paid > 0 && r.balance > 0 ? 'partial' : r.balance <= 0 ? 'ok' : 'pending');
return status === selected;
});
}
if (!rows.length) { if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">No hay cuentas por pagar.</td></tr>'; tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">No hay cuentas por pagar.</td></tr>';
return; return;
} }
tbody.innerHTML = rows.map(r => { tbody.innerHTML = rows.map(r => {
const status = r.status || (r.days_overdue > 0 ? 'overdue' : r.paid > 0 && r.balance > 0 ? 'partial' : r.balance <= 0 ? 'ok' : 'pending'); const status = r.days_overdue > 0 ? 'overdue' : r.paid > 0 && r.balance > 0 ? 'partial' : r.balance <= 0 ? 'ok' : 'pending';
const label = r.status_label || (status === 'overdue' ? 'Vencida' : status === 'partial' ? 'Parcial' : status === 'ok' ? 'Pagada' : 'Pendiente'); const label = status === 'overdue' ? 'Vencida' : status === 'partial' ? 'Parcial' : status === 'ok' ? 'Pagada' : 'Vigente';
return `<tr> return `<tr>
<td class="td--mono">${r.invoice || r.folio || '-'}</td> <td class="td--mono">${r.invoice || r.folio || '-'}</td>
<td class="td--primary">${r.vendor_name || r.name || '-'}</td> <td class="td--primary">${r.name || r.vendor_name || '-'}</td>
<td>${r.issue_date ? new Date(r.issue_date).toLocaleDateString('es-MX') : '-'}</td> <td>${r.receipt_date ? new Date(r.receipt_date).toLocaleDateString('es-MX') : '-'}</td>
<td>${r.due_date ? new Date(r.due_date).toLocaleDateString('es-MX') : '-'}</td> <td>${r.due_date ? new Date(r.due_date).toLocaleDateString('es-MX') : '-'}</td>
<td class="td--amount">$${fmt(r.total)}</td> <td class="td--amount">$${fmt(r.total)}</td>
<td class="td--amount">$${fmt(r.paid || 0)}</td> <td class="td--amount">$${fmt(r.paid || 0)}</td>
<td class="td--amount">$${fmt(r.balance || r.total)}</td> <td class="td--amount">$${fmt(r.balance || r.total)}</td>
<td>${statusBadge(status, label)}</td> <td>${statusBadge(status, label)}</td>
<td><button class="btn btn--ghost btn--sm" onclick="Accounting.registerPayablePayment(${r.id || 0})">${r.balance > 0 ? 'Pagar' : 'Ver'}</button></td> <td><button class="btn btn--ghost btn--sm">${r.balance > 0 ? 'Pagar' : 'Ver'}</button></td>
</tr>`; </tr>`;
}).join(''); }).join('');
@@ -520,8 +384,6 @@ const Accounting = (() => {
loadSummaryCards(); loadSummaryCards();
// Load initial tab data (cxc is active by default) // Load initial tab data (cxc is active by default)
loadAging(); loadAging();
// Preload chart of accounts for the manual entry modal
loadChartOfAccounts();
} }
document.addEventListener('DOMContentLoaded', init); document.addEventListener('DOMContentLoaded', init);
@@ -561,27 +423,6 @@ const Accounting = (() => {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
async function exportarCuentasPorCobrar() {
try {
const res = await fetch(`${API}/aging/export?type=receivable&format=pdf`, { headers: headers() });
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || 'Error al exportar');
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'ventas_por_cobrar_' + new Date().toISOString().slice(0, 10) + '.pdf';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e) {
alert('Error al exportar: ' + e.message);
}
}
// ---- Nueva Poliza modal ---- // ---- Nueva Poliza modal ----
function showNewEntryModal() { function showNewEntryModal() {
const overlay = document.getElementById('newEntryModalOverlay'); const overlay = document.getElementById('newEntryModalOverlay');
@@ -607,20 +448,13 @@ const Accounting = (() => {
line.className = 'entry-line'; line.className = 'entry-line';
line.style.cssText = 'display:grid;grid-template-columns:2fr 1fr 1fr auto;gap:var(--space-2);margin-bottom:var(--space-2);align-items:center;'; line.style.cssText = 'display:grid;grid-template-columns:2fr 1fr 1fr auto;gap:var(--space-2);margin-bottom:var(--space-2);align-items:center;';
line.innerHTML = line.innerHTML =
accountSelectHtml() + '<input type="text" placeholder="Cuenta contable" class="entry-account" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);" />' +
'<input type="number" placeholder="Debe" class="entry-debit" step="0.01" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);" />' + '<input type="number" placeholder="Debe" class="entry-debit" step="0.01" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);" />' +
'<input type="number" placeholder="Haber" class="entry-credit" step="0.01" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);" />' + '<input type="number" placeholder="Haber" class="entry-credit" step="0.01" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);" />' +
'<button class="btn btn--ghost btn--sm" onclick="this.closest(\'.entry-line\').remove()">&times;</button>'; '<button class="btn btn--ghost btn--sm" onclick="this.closest(\'.entry-line\').remove()">&times;</button>';
container.appendChild(line); container.appendChild(line);
} }
function populateEntryAccountSelects() {
document.querySelectorAll('#entryLines .entry-account').forEach(sel => {
const selected = sel.value;
sel.outerHTML = accountSelectHtml(selected);
});
}
async function submitNewEntry() { async function submitNewEntry() {
const date = document.getElementById('entryDate').value; const date = document.getElementById('entryDate').value;
const type = document.getElementById('entryType').value; const type = document.getElementById('entryType').value;
@@ -634,16 +468,16 @@ const Accounting = (() => {
const lines = []; const lines = [];
document.querySelectorAll('#entryLines .entry-line').forEach(row => { document.querySelectorAll('#entryLines .entry-line').forEach(row => {
const accountId = row.querySelector('.entry-account').value; const account = row.querySelector('.entry-account').value.trim();
const debit = parseFloat(row.querySelector('.entry-debit').value) || 0; const debit = parseFloat(row.querySelector('.entry-debit').value) || 0;
const credit = parseFloat(row.querySelector('.entry-credit').value) || 0; const credit = parseFloat(row.querySelector('.entry-credit').value) || 0;
if (accountId && (debit || credit)) { if (account && (debit || credit)) {
lines.push({ account_id: parseInt(accountId, 10), debit, credit }); lines.push({ account, debit, credit });
} }
}); });
if (lines.length < 2) { if (!lines.length) {
resultEl.innerHTML = '<span style="color:var(--color-error);">Agregue al menos dos partidas.</span>'; resultEl.innerHTML = '<span style="color:var(--color-error);">Agregue al menos una partida.</span>';
return; return;
} }
@@ -661,23 +495,17 @@ const Accounting = (() => {
// Expose switchTab globally for onclick handlers in HTML // Expose switchTab globally for onclick handlers in HTML
window.switchTab = switchTab; window.switchTab = switchTab;
function registerPayablePayment(payableId) {
// Placeholder until supplier payments module is implemented
alert('Registrar pago a proveedor — próximamente' + (payableId ? ' (OC ' + payableId + ')' : ''));
}
function runPeriodClose() {
// Placeholder until fiscal period close module is implemented
alert('Ejecutar cierre de período — próximamente');
}
window.exportarContabilidad = exportarContabilidad; window.exportarContabilidad = exportarContabilidad;
window.exportarCuentasPorCobrar = exportarCuentasPorCobrar;
window.showNewEntryModal = showNewEntryModal; window.showNewEntryModal = showNewEntryModal;
window.closeNewEntryModal = closeNewEntryModal; window.closeNewEntryModal = closeNewEntryModal;
window.addEntryLine = addEntryLine; window.addEntryLine = addEntryLine;
window.submitNewEntry = submitNewEntry; window.submitNewEntry = submitNewEntry;
return {
switchTab, loadAging, loadAccountsPayable, loadBalanceSheet,
loadIncomeStatement, loadCashFlow, loadReconciliation, loadPeriodClose,
exportarContabilidad, showNewEntryModal, closeNewEntryModal, addEntryLine, submitNewEntry,
};
// Register Cmd+K items // Register Cmd+K items
if (typeof registerCmdKItem === "function") { if (typeof registerCmdKItem === "function") {
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" }); registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
@@ -686,13 +514,4 @@ const Accounting = (() => {
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" }); registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
} }
window.Accounting = {
switchTab, loadAging, loadAccountsPayable, loadBalanceSheet,
loadIncomeStatement, loadCashFlow, loadReconciliation, loadPeriodClose,
exportarContabilidad, showNewEntryModal, closeNewEntryModal, addEntryLine, submitNewEntry,
showReceivableDetail, closeReceivableDetail, cancelReceivable,
registerPayablePayment, runPeriodClose,
};
return window.Accounting;
})(); })();

1
pos/static/js/accounting.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,27 +0,0 @@
// /home/Autopartes/pos/static/js/api.js
// Minimal shared API helper used by standalone pages (e.g. historical_sales).
async function api(path, options = {}) {
const token = localStorage.getItem('pos_token') || '';
const url = path.startsWith('http') ? path : path;
const res = await fetch(url, {
...options,
headers: {
'Authorization': token ? 'Bearer ' + token : '',
'Content-Type': 'application/json',
...(options.headers || {})
}
});
if (res.status === 401) {
window.location.href = '/pos/login';
throw new Error('Sesión expirada');
}
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error || data.message || 'Error ' + res.status);
}
return data;
}

View File

@@ -43,8 +43,7 @@
var _t = typeof window.t === 'function' ? window.t : function(k) { return k; }; var _t = typeof window.t === 'function' ? window.t : function(k) { return k; };
var roleLabels = { var roleLabels = {
'owner': _t('role_owner'), 'admin': _t('role_admin'), 'cashier': _t('role_cashier'), 'owner': _t('role_owner'), 'admin': _t('role_admin'), 'cashier': _t('role_cashier'),
'warehouse': _t('role_warehouse'), 'accountant': _t('role_accountant'), 'warehouse': _t('role_warehouse'), 'accountant': _t('role_accountant')
'workshop': 'Taller', 'mechanic': 'Mecanico'
}; };
var roleLabel = roleLabels[role] || role; var roleLabel = roleLabels[role] || role;
var initials = name.split(' ').map(function(p) { return p[0]; }).join('').toUpperCase().substring(0, 2); var initials = name.split(' ').map(function(p) { return p[0]; }).join('').toUpperCase().substring(0, 2);
@@ -118,7 +117,6 @@
localStorage.removeItem('pos_employee'); localStorage.removeItem('pos_employee');
localStorage.removeItem('pos_tenant_id'); localStorage.removeItem('pos_tenant_id');
localStorage.removeItem('pos_cart'); localStorage.removeItem('pos_cart');
document.cookie = 'pos_role=; path=/pos; SameSite=Lax; expires=Thu, 01 Jan 1970 00:00:00 GMT';
window.location.href = '/pos/login'; window.location.href = '/pos/login';
}; };
@@ -182,147 +180,6 @@
permissions: payload.permissions || [] permissions: payload.permissions || []
}; };
// ─── Page guard based on role + permissions ───
function moduleEnabled(key) {
try {
var modules = JSON.parse(localStorage.getItem('pos_modules') || '{}');
return modules[key] !== false;
} catch(e) { return true; }
}
function updateHomeLinks(userRole, userPerms) {
// Point the dashboard/home icon to the role-specific default page.
var homeHref = '/pos/dashboard';
if (userRole === 'counter' && userPerms.indexOf('inventory.view') !== -1) {
homeHref = '/pos/inventory';
} else if (userRole === 'cashier' && userPerms.indexOf('pos.sell') !== -1) {
homeHref = '/pos/sale';
} else if (userRole === 'workshop' && userPerms.indexOf('workshop.view') !== -1 && moduleEnabled('workshop')) {
homeHref = '/pos/workshop';
}
document.querySelectorAll('a[href="/pos/dashboard"]').forEach(function(a) {
a.setAttribute('href', homeHref);
});
}
function isPageAllowed(pagePath, userRole, userPerms) {
if (userRole === 'owner' || userRole === 'admin') return true;
// Restricted roles (workshop/mechanic/counter/cashier) see modules based on permissions.
if (['workshop', 'mechanic', 'counter', 'cashier'].indexOf(userRole) !== -1) {
var allowed = [];
if ((userRole === 'workshop' || userRole === 'mechanic') && moduleEnabled('workshop')) {
allowed = ['/pos/workshop'];
}
var permMap = {
'pos.sell': '/pos/sale',
'pos.view': '/pos/sale',
'catalog.view': '/pos/catalog',
'inventory.view': '/pos/inventory',
'customers.view': '/pos/customers',
'workshop.view': '/pos/workshop',
'pos.remission': '/pos/remission-notes',
'invoicing.view': '/pos/invoicing',
'quotations.view': '/pos/quotations',
'accounting.view': '/pos/accounting',
'reports.view': '/pos/reports',
'dashboard.view': '/pos/dashboard'
};
for (var p in permMap) {
if (userPerms.indexOf(p) !== -1 && allowed.indexOf(permMap[p]) === -1) {
// Hide workshop if its module is disabled
if (permMap[p] === '/pos/workshop' && !moduleEnabled('workshop')) continue;
allowed.push(permMap[p]);
}
}
return allowed.indexOf(pagePath) !== -1;
}
// Always allow login/logout pages so users can sign out without hitting the guard.
if (pagePath === '/pos/login' || pagePath === '/pos/logout') return true;
// Any other role (accountant, warehouse, sales, etc.) keeps the previous permissive behavior.
return true;
}
function enforcePageGuard(userRole, userPerms) {
if (isPageAllowed(path, userRole, userPerms)) return true;
// Build the actual list of allowed pages so we can pick a safe fallback.
var fallback = null;
if (userRole === 'owner' || userRole === 'admin') {
fallback = '/pos/dashboard';
} else if (['workshop', 'mechanic', 'counter', 'cashier'].indexOf(userRole) !== -1) {
var allowed = [];
if ((userRole === 'workshop' || userRole === 'mechanic') && moduleEnabled('workshop')) {
allowed = ['/pos/workshop'];
}
var permMap = {
'pos.sell': '/pos/sale',
'pos.view': '/pos/sale',
'catalog.view': '/pos/catalog',
'inventory.view': '/pos/inventory',
'customers.view': '/pos/customers',
'workshop.view': '/pos/workshop',
'pos.remission': '/pos/remission-notes',
'invoicing.view': '/pos/invoicing',
'quotations.view': '/pos/quotations',
'accounting.view': '/pos/accounting',
'reports.view': '/pos/reports',
'dashboard.view': '/pos/dashboard'
};
for (var p in permMap) {
if (userPerms.indexOf(p) !== -1 && allowed.indexOf(permMap[p]) === -1) {
if (permMap[p] === '/pos/workshop' && !moduleEnabled('workshop')) continue;
allowed.push(permMap[p]);
}
}
// Role-specific default landing pages.
if (userRole === 'counter' && allowed.indexOf('/pos/inventory') !== -1) {
fallback = '/pos/inventory';
} else if (userRole === 'cashier' && allowed.indexOf('/pos/sale') !== -1) {
fallback = '/pos/sale';
} else {
fallback = allowed.length ? allowed[0] : '/pos/login';
}
} else {
fallback = '/pos/dashboard';
}
window.location.replace(fallback);
return false;
}
// ─── Refresh permissions/token from server before enforcing the guard ───
// This makes permission changes effective without requiring a full re-login.
try {
fetch('/pos/api/auth/refresh', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token }
}).then(function(r) {
if (r.ok) return r.json();
return null;
}).then(function(data) {
if (data && data.token) {
localStorage.setItem('pos_token', data.token);
localStorage.setItem('pos_employee', JSON.stringify(data.employee));
token = data.token;
window.POS_USER.token = data.token;
window.POS_USER.permissions = data.permissions || [];
window.POS_USER.branchId = data.employee.branch_id;
}
if (!enforcePageGuard(window.POS_USER.role, window.POS_USER.permissions)) return;
if (typeof window.renderSidebar === 'function') {
window.renderSidebar(window.POS_USER.modules || JSON.parse(localStorage.getItem('pos_modules') || '{}'));
}
updateHomeLinks(window.POS_USER.role, window.POS_USER.permissions);
}).catch(function() {
enforcePageGuard(role, payload.permissions || []);
updateHomeLinks(role, payload.permissions || []);
});
} catch(e) {
enforcePageGuard(role, payload.permissions || []);
updateHomeLinks(role, payload.permissions || []);
}
// ─── Preload enabled modules for sidebar filtering ─── // ─── Preload enabled modules for sidebar filtering ───
try { try {
fetch('/pos/api/config/modules', { fetch('/pos/api/config/modules', {
@@ -340,16 +197,6 @@
}).catch(function() {}); }).catch(function() {});
} catch(e) {} } catch(e) {}
// ─── Hide POS "Sistema" button for roles that cannot access the dashboard ───
(function hideBackToSystemForRestrictedRoles() {
var backBtn = document.getElementById('backToSystemBtn');
if (!backBtn) return;
// owner/admin always see it; others only if they have dashboard.view.
if (role === 'owner' || role === 'admin') return;
if ((window.POS_USER.permissions || []).indexOf('dashboard.view') !== -1) return;
backBtn.style.display = 'none';
})();
// ─── Service Worker update handler ─── // ─── Service Worker update handler ───
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', function (event) { navigator.serviceWorker.addEventListener('message', function (event) {
@@ -360,21 +207,4 @@
}); });
} }
// ─── Global toast utility ───
window.toast = function(msg, type) {
type = type || 'success';
var bg = type === 'error' ? '#d32f2f' : '#388e3c';
var el = document.createElement('div');
el.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:99999;padding:12px 20px;border-radius:8px;background:' + bg + ';color:#fff;font-weight:500;box-shadow:0 4px 12px rgba(0,0,0,.2);opacity:0;transition:opacity .3s;';
el.textContent = msg;
document.body.appendChild(el);
// trigger reflow
el.offsetHeight;
el.style.opacity = '1';
setTimeout(function() {
el.style.opacity = '0';
setTimeout(function() { if (el.parentNode) el.parentNode.removeChild(el); }, 300);
}, 3000);
};
})(); })();

View File

@@ -4,9 +4,6 @@
const Config = (() => { const Config = (() => {
const API = '/pos/api/config'; const API = '/pos/api/config';
const user = window.POS_USER || {};
const canDeleteBranch = (user.role === 'owner' || user.role === 'admin');
// Cache for branches (used by employee modal selector) // Cache for branches (used by employee modal selector)
let _branches = []; let _branches = [];
@@ -58,7 +55,7 @@ const Config = (() => {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Theme switcher // Theme switcher
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
function setTheme(theme) { /*function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-theme', theme);
try { localStorage.setItem('pos_theme', theme); } catch(e) {} try { localStorage.setItem('pos_theme', theme); } catch(e) {}
@@ -73,7 +70,22 @@ const Config = (() => {
var opts = document.querySelectorAll('.theme-option'); var opts = document.querySelectorAll('.theme-option');
if (opts[idx]) opts[idx].classList.add('is-selected'); if (opts[idx]) opts[idx].classList.add('is-selected');
} }
window.setTheme = setTheme; window.setTheme = setTheme;*/
function setTheme(theme) {
if (window.posSetTheme) window.posSetTheme(theme);
document.querySelectorAll('.theme-btn').forEach(function(btn) {
btn.classList.toggle('is-active', btn.dataset.themeTarget === theme);
});
document.querySelectorAll('.theme-option').forEach(function(opt) {
opt.classList.remove('is-selected');
});
var idx = theme === 'industrial' ? 0 : 1;
var opts = document.querySelectorAll('.theme-option');
if (opts[idx]) opts[idx].classList.add('is-selected');
}
function selectThemeOption(theme) { function selectThemeOption(theme) {
setTheme(theme); setTheme(theme);
@@ -108,22 +120,16 @@ const Config = (() => {
owner: 'Dueno', owner: 'Dueno',
admin: 'Admin', admin: 'Admin',
cashier: 'Cajero', cashier: 'Cajero',
counter: 'Mostrador',
warehouse: 'Almacenista', warehouse: 'Almacenista',
accountant: 'Contador', accountant: 'Contador'
workshop: 'Taller',
mechanic: 'Mecanico'
}; };
var ROLE_BADGE = { var ROLE_BADGE = {
owner: 'badge--owner', owner: 'badge--owner',
admin: 'badge--blue', admin: 'badge--blue',
cashier: 'badge--green', cashier: 'badge--green',
counter: 'badge--gray',
warehouse: 'badge--yellow', warehouse: 'badge--yellow',
accountant: 'badge--purple', accountant: 'badge--purple'
workshop: 'badge--orange',
mechanic: 'badge--teal'
}; };
function roleBadge(role) { function roleBadge(role) {
@@ -188,7 +194,6 @@ const Config = (() => {
+ '</div>' + '</div>'
+ '<div class="device-card__actions">' + '<div class="device-card__actions">'
+ '<button class="btn btn--ghost btn--sm" onclick="Config.editBranch(' + b.id + ')">Editar</button>' + '<button class="btn btn--ghost btn--sm" onclick="Config.editBranch(' + b.id + ')">Editar</button>'
+ (canDeleteBranch && b.is_active && !b.is_main ? '<button class="btn btn--danger btn--sm" style="margin-left:4px;" onclick="Config.deleteBranch(' + b.id + ')">Eliminar</button>' : '')
+ '</div></div>'; + '</div></div>';
}); });
@@ -244,22 +249,6 @@ const Config = (() => {
openBranchModal(b); openBranchModal(b);
} }
async function deleteBranch(branchId) {
var b = _branches.find(function(x) { return x.id === branchId; });
if (!b) { toast('Sucursal no encontrada', 'error'); return; }
if (b.is_main) { toast('No se puede eliminar la sucursal principal', 'error'); return; }
if (!confirm('¿Eliminar la sucursal "' + b.name + '"? Se marcará como inactiva.')) return;
try {
var res = await fetch(API + '/branches/' + branchId, { method: 'DELETE', headers: headers() });
var json = await res.json().catch(function() { return {}; });
if (!res.ok) throw new Error(json.error || res.statusText);
toast('Sucursal eliminada');
loadBranches();
} catch (e) {
toast(e.message || 'Error al eliminar sucursal', 'error');
}
}
async function saveBranch(data) { async function saveBranch(data) {
var branchId = document.getElementById('branch-id').value; var branchId = document.getElementById('branch-id').value;
var url = API + '/branches' + (branchId ? '/' + branchId : ''); var url = API + '/branches' + (branchId ? '/' + branchId : '');
@@ -320,10 +309,7 @@ const Config = (() => {
+ '<td>' + escHtml(emp.branch_name || 'Todas') + '</td>' + '<td>' + escHtml(emp.branch_name || 'Todas') + '</td>'
+ '<td>' + statusBadge + '</td>' + '<td>' + statusBadge + '</td>'
+ '<td>' + (emp.max_discount_pct || 0) + '%</td>' + '<td>' + (emp.max_discount_pct || 0) + '%</td>'
+ '<td>' + '<td><button class="btn btn--ghost btn--sm" onclick="Config.editEmployee(' + emp.id + ')">Editar</button></td>'
+ '<button class="btn btn--ghost btn--sm" onclick="Config.editEmployee(' + emp.id + ')">Editar</button>'
+ (emp.role !== 'owner' ? ' <button class="btn btn--danger btn--sm" onclick="Config.deleteEmployee(' + emp.id + ', \'' + escHtml(emp.name).replace(/\\/g, '\\\\').replace(/'/g, "\\'") + '\')">Eliminar</button>' : '')
+ '</td>'
+ '</tr>'; + '</tr>';
}); });
@@ -333,7 +319,7 @@ const Config = (() => {
async function saveEmployee(data) { async function saveEmployee(data) {
// Check if we're editing (modal has editId) or creating // Check if we're editing (modal has editId) or creating
var modal = document.getElementById('modal-employee'); var modal = document.getElementById('employee-modal');
var editId = modal ? modal.dataset.editId : null; var editId = modal ? modal.dataset.editId : null;
var url = API + '/employees'; var url = API + '/employees';
var method = 'POST'; var method = 'POST';
@@ -387,21 +373,6 @@ const Config = (() => {
return el ? el.value.trim() : ''; return el ? el.value.trim() : '';
} }
async function deleteEmployee(empId, name) {
if (!confirm('¿Eliminar al empleado "' + name + '"? Esta accion lo desactiva.')) return;
try {
var res = await fetch(API + '/employees/' + empId, { method: 'DELETE', headers: headers() });
if (!res.ok) {
var err = await res.json().catch(function() { return { error: 'Error ' + res.status }; });
throw new Error(err.error || 'Error al eliminar');
}
toast('Empleado eliminado', 'ok');
loadEmployees();
} catch (e) {
toast(e.message, 'error');
}
}
async function editEmployee(empId) { async function editEmployee(empId) {
if (!checkAuth()) return; if (!checkAuth()) return;
// Find the employee in the loaded data by re-fetching // Find the employee in the loaded data by re-fetching
@@ -412,25 +383,24 @@ const Config = (() => {
var emp = (json.data || []).find(function(e) { return e.id === empId; }); var emp = (json.data || []).find(function(e) { return e.id === empId; });
if (!emp) { toast('Empleado no encontrado', 'error'); return; } if (!emp) { toast('Empleado no encontrado', 'error'); return; }
// Pre-fill the employee modal with existing data for editing // Pre-fill the "new employee" modal with existing data for editing
setVal('emp-name', emp.name); setVal('new-emp-name', emp.name);
setVal('emp-email', emp.email || ''); setVal('new-emp-email', emp.email || '');
setVal('emp-phone', emp.phone || ''); var roleSelect = document.getElementById('new-emp-role');
var roleSelect = document.getElementById('emp-role');
if (roleSelect) roleSelect.value = emp.role || 'cashier'; if (roleSelect) roleSelect.value = emp.role || 'cashier';
var branchSelect = document.getElementById('emp-branch'); var branchSelect = document.getElementById('new-emp-branch');
if (branchSelect) branchSelect.value = emp.branch_id || ''; if (branchSelect) branchSelect.value = emp.branch_id || '';
setVal('emp-discount', emp.max_discount_pct || ''); setVal('new-emp-discount', emp.max_discount_pct || '');
setVal('emp-pin', ''); // Don't pre-fill PIN for security setVal('new-emp-pin', ''); // Don't pre-fill PIN for security
// Store the ID so saveEmployee knows it's an update // Store the ID so saveEmployee knows it's an update
var modal = document.getElementById('modal-employee'); var modal = document.getElementById('employee-modal');
if (modal) { if (modal) {
modal.dataset.editId = empId; modal.dataset.editId = empId;
var title = modal.querySelector('.modal-title, h3'); var title = modal.querySelector('.modal-title, h3');
if (title) title.textContent = 'Editar Empleado'; if (title) title.textContent = 'Editar Empleado';
} }
openModal('modal-employee'); openModal('employee-modal');
} catch (e) { } catch (e) {
toast('Error: ' + e.message, 'error'); toast('Error: ' + e.message, 'error');
} }
@@ -487,156 +457,6 @@ const Config = (() => {
} }
} }
// -------------------------------------------------------------------------
// Receipt / ticket customization
// -------------------------------------------------------------------------
let _receiptLogo = '';
async function loadReceiptConfig() {
try {
var res = await fetch(API + '/receipt', { headers: headers() });
if (!res.ok) return;
var d = await res.json();
_receiptLogo = d.logo || '';
setVal('receipt-store-name', d.store_name);
setVal('receipt-tagline', d.tagline);
setVal('receipt-rfc', d.rfc);
setVal('receipt-address', d.address);
setVal('receipt-phone', d.phone);
setVal('receipt-paper-width', d.paper_width || '80');
setVal('receipt-thanks', d.thanks_message);
setVal('receipt-footer', d.footer);
setChecked('receipt-show-logo', d.show_logo);
setChecked('receipt-show-rfc', d.show_rfc);
setChecked('receipt-show-address', d.show_address);
setChecked('receipt-show-phone', d.show_phone);
setChecked('receipt-show-iva', d.show_iva_breakdown);
setChecked('receipt-show-payment', d.show_payment_details);
setChecked('receipt-show-employee', d.show_employee);
renderReceiptLogoThumb();
} catch (e) {
console.error('Config.loadReceiptConfig:', e);
}
}
function setChecked(id, v) {
var el = document.getElementById(id);
if (el) el.checked = !!v;
}
function getChecked(id) {
var el = document.getElementById(id);
return el ? el.checked : false;
}
function renderReceiptLogoThumb() {
var thumb = document.getElementById('receipt-logo-thumb');
var removeBtn = document.getElementById('receipt-logo-remove');
if (!thumb) return;
if (_receiptLogo) {
thumb.innerHTML = '<img src="' + escapeHtml(_receiptLogo) + '" style="max-width:100%;max-height:100%;object-fit:contain;" alt="Logo ticket">';
if (removeBtn) removeBtn.style.display = '';
} else {
thumb.innerHTML = '<span style="color:var(--color-text-muted);font-size:var(--text-caption);text-align:center;padding:var(--space-2);">Sin logo</span>';
if (removeBtn) removeBtn.style.display = 'none';
}
}
function handleReceiptLogo(input) {
var file = input && input.files ? input.files[0] : null;
if (!file) return;
if (!file.type.match(/^image\/(png|jpeg|jpg|webp)$/)) {
toast('Solo se permiten imágenes PNG, JPG o WebP', 'error');
input.value = '';
return;
}
var reader = new FileReader();
reader.onload = function(e) {
var img = new Image();
img.onload = function() {
var maxWidth = 300;
var scale = Math.min(1, maxWidth / img.width);
var w = Math.round(img.width * scale);
var h = Math.round(img.height * scale);
var canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
var ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, w, h);
ctx.drawImage(img, 0, 0, w, h);
_receiptLogo = canvas.toDataURL('image/jpeg', 0.85);
renderReceiptLogoThumb();
toast('Logo cargado. Guarda los cambios para aplicarlo.', 'ok');
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
input.value = '';
}
function removeReceiptLogo() {
_receiptLogo = '';
renderReceiptLogoThumb();
}
async function saveReceiptConfig() {
if (!checkAuth()) return;
var data = {
logo: _receiptLogo,
store_name: getVal('receipt-store-name'),
tagline: getVal('receipt-tagline'),
rfc: getVal('receipt-rfc'),
address: getVal('receipt-address'),
phone: getVal('receipt-phone'),
paper_width: getVal('receipt-paper-width') || '80',
thanks_message: getVal('receipt-thanks'),
footer: getVal('receipt-footer'),
show_logo: getChecked('receipt-show-logo'),
show_rfc: getChecked('receipt-show-rfc'),
show_address: getChecked('receipt-show-address'),
show_phone: getChecked('receipt-show-phone'),
show_iva_breakdown: getChecked('receipt-show-iva'),
show_payment_details: getChecked('receipt-show-payment'),
show_employee: getChecked('receipt-show-employee'),
};
try {
var res = await fetch(API + '/receipt', {
method: 'PUT',
headers: headers(),
body: JSON.stringify(data),
});
if (!res.ok) {
var err = await res.json().catch(function() { return { error: res.statusText }; });
throw new Error(err.error || 'Error al guardar');
}
toast('Configuración de ticket guardada', 'ok');
} catch (e) {
toast(e.message, 'error');
}
}
async function saveAll() {
if (!checkAuth()) return;
var btn = document.getElementById('btn-save-all');
if (btn) { btn.disabled = true; btn.textContent = 'Guardando...'; }
try {
await saveBusiness();
await saveTaxParams();
await saveCurrency();
await saveVehicleCompatSource();
await saveAllowedBrands();
await saveModules();
await saveSalesSettings();
await saveReceiptConfig();
toast('Configuración guardada', 'ok');
} catch (e) {
toast(e.message, 'error');
} finally {
if (btn) { btn.disabled = false; btn.textContent = 'Guardar Cambios'; }
}
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Event bindings // Event bindings
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -701,12 +521,6 @@ const Config = (() => {
var btnNewEmp = document.getElementById('btn-new-employee'); var btnNewEmp = document.getElementById('btn-new-employee');
if (btnNewEmp) { if (btnNewEmp) {
btnNewEmp.addEventListener('click', function() { btnNewEmp.addEventListener('click', function() {
var modal = document.getElementById('modal-employee');
if (modal) {
delete modal.dataset.editId;
var title = modal.querySelector('.modal-title, h3');
if (title) title.textContent = 'Nuevo Empleado';
}
openModal('modal-employee'); openModal('modal-employee');
}); });
} }
@@ -730,7 +544,6 @@ const Config = (() => {
btnSaveEmp.disabled = true; btnSaveEmp.disabled = true;
btnSaveEmp.textContent = 'Guardando...'; btnSaveEmp.textContent = 'Guardando...';
var isEdit = !!document.getElementById('modal-employee').dataset.editId;
try { try {
await saveEmployee({ await saveEmployee({
name: name, name: name,
@@ -741,7 +554,7 @@ const Config = (() => {
branch_id: branchId ? parseInt(branchId, 10) : null, branch_id: branchId ? parseInt(branchId, 10) : null,
max_discount_pct: parseFloat(document.getElementById('emp-discount').value) || 0 max_discount_pct: parseFloat(document.getElementById('emp-discount').value) || 0
}); });
toast(isEdit ? 'Empleado actualizado' : 'Empleado creado'); toast('Empleado creado');
closeModal('modal-employee'); closeModal('modal-employee');
// Reset form // Reset form
document.getElementById('emp-name').value = ''; document.getElementById('emp-name').value = '';
@@ -958,42 +771,25 @@ const Config = (() => {
var cbMp = document.getElementById('cfg-module-marketplace'); var cbMp = document.getElementById('cfg-module-marketplace');
var cbMeli = document.getElementById('cfg-module-meli'); var cbMeli = document.getElementById('cfg-module-meli');
var cbCat = document.getElementById('cfg-module-catalog'); var cbCat = document.getElementById('cfg-module-catalog');
var cbWork = document.getElementById('cfg-module-workshop');
if (cbWa) cbWa.checked = data.whatsapp !== false; if (cbWa) cbWa.checked = data.whatsapp !== false;
if (cbMp) cbMp.checked = data.marketplace !== false; if (cbMp) cbMp.checked = data.marketplace !== false;
if (cbMeli) cbMeli.checked = data.meli !== false; if (cbMeli) cbMeli.checked = data.meli !== false;
if (cbCat) cbCat.checked = data.catalog !== false; if (cbCat) cbCat.checked = data.catalog !== false;
if (cbWork) cbWork.checked = data.workshop !== false;
localStorage.setItem('pos_modules', JSON.stringify(data)); localStorage.setItem('pos_modules', JSON.stringify(data));
} catch (e) { } catch (e) {
console.error('Config.loadModules:', e); console.error('Config.loadModules:', e);
} }
try {
var res2 = await fetch(API + '/counter-remission', { headers: headers() });
if (!res2.ok) return;
var d2 = await res2.json();
var cbCr = document.getElementById('cfg-module-counter-remission');
if (cbCr) cbCr.checked = d2.enabled === true;
} catch (e) {
console.error('Config.loadCounterRemission:', e);
}
} }
async function saveModules() { async function saveModules() {
var cbWa = document.getElementById('cfg-module-whatsapp'); var btn = event.target;
var cbMp = document.getElementById('cfg-module-marketplace'); if (btn) { btn.disabled = true; btn.textContent = 'Guardando...'; }
var cbMeli = document.getElementById('cfg-module-meli');
var cbCat = document.getElementById('cfg-module-catalog');
var cbWork = document.getElementById('cfg-module-workshop');
var cbCr = document.getElementById('cfg-module-counter-remission');
if (!cbWa && !cbMp && !cbMeli && !cbCat && !cbWork && !cbCr) return;
try { try {
var data = { var data = {
whatsapp: cbWa ? cbWa.checked : true, whatsapp: document.getElementById('cfg-module-whatsapp').checked,
marketplace: cbMp ? cbMp.checked : true, marketplace: document.getElementById('cfg-module-marketplace').checked,
meli: cbMeli ? cbMeli.checked : true, meli: document.getElementById('cfg-module-meli').checked,
catalog: cbCat ? cbCat.checked : true, catalog: document.getElementById('cfg-module-catalog').checked,
workshop: cbWork ? cbWork.checked : true,
}; };
var res = await fetch(API + '/modules', { var res = await fetch(API + '/modules', {
method: 'PUT', method: 'PUT',
@@ -1005,257 +801,14 @@ const Config = (() => {
throw new Error(err.error || 'Save failed'); throw new Error(err.error || 'Save failed');
} }
localStorage.setItem('pos_modules', JSON.stringify(data)); localStorage.setItem('pos_modules', JSON.stringify(data));
if (cbCr) {
var res2 = await fetch(API + '/counter-remission', {
method: 'PUT',
headers: headers(),
body: JSON.stringify({ enabled: cbCr.checked })
});
if (!res2.ok) {
var err2 = await res2.json().catch(function() { return { error: res2.statusText }; });
throw new Error(err2.error || 'Save failed');
}
}
toast('Módulos actualizados'); toast('Módulos actualizados');
} catch (e) { } catch (e) {
toast(e.message, 'error'); toast(e.message, 'error');
} finally {
if (btn) { btn.disabled = false; btn.textContent = 'Guardar módulos'; }
} }
} }
async function loadSalesSettings() {
try {
var res = await fetch(API + '/sales-settings', { headers: headers() });
if (!res.ok) return;
var data = await res.json();
var cbZero = document.getElementById('cfg-allow-zero-price');
if (cbZero) cbZero.checked = data.allow_zero_price_sales !== false;
var cbNeg = document.getElementById('cfg-allow-negative-stock');
if (cbNeg) cbNeg.checked = data.allow_negative_stock === true;
} catch (e) {
console.error('Config.loadSalesSettings:', e);
}
}
async function saveSalesSettings() {
var cbZero = document.getElementById('cfg-allow-zero-price');
var cbNeg = document.getElementById('cfg-allow-negative-stock');
if (!cbZero && !cbNeg) return;
try {
var body = {};
if (cbZero) body.allow_zero_price_sales = cbZero.checked;
if (cbNeg) body.allow_negative_stock = cbNeg.checked;
var res = await fetch(API + '/sales-settings', {
method: 'PUT',
headers: headers(),
body: JSON.stringify(body)
});
if (!res.ok) {
var err = await res.json().catch(function() { return { error: res.statusText }; });
throw new Error(err.error || 'Save failed');
}
} catch (e) {
toast(e.message, 'error');
throw e;
}
}
// -------------------------------------------------------------------------
// Tab navigation
// -------------------------------------------------------------------------
function switchTab(tab) {
document.querySelectorAll('.cfg-tab-btn').forEach(function(btn) {
btn.classList.toggle('active', btn.dataset.tab === tab);
});
document.querySelectorAll('.settings-section[data-tab]').forEach(function(sec) {
var isActive = sec.dataset.tab === tab;
sec.classList.toggle('active', isActive);
sec.style.display = isActive ? '' : 'none';
});
try { localStorage.setItem('pos_config_tab', tab); } catch(e) {}
}
// -------------------------------------------------------------------------
// Role permissions editor
// -------------------------------------------------------------------------
var _rolePermissions = {};
var _availablePermissions = [];
var _workshopPermissions = {};
var _workshopSchema = { statuses: [], actions: [] };
var _currentPermTab = 'modules';
async function loadRolePermissions() {
try {
var res = await fetch(API + '/role-permissions', { headers: headers() });
if (!res.ok) return;
var data = await res.json();
_rolePermissions = data.roles || {};
_availablePermissions = data.available || [];
renderRolePermissions();
} catch (e) {
console.error('Config.loadRolePermissions:', e);
}
}
function renderRolePermissions() {
var container = document.getElementById('role-permissions-container');
var roleSel = document.getElementById('cfg-perm-role');
if (!container || !roleSel) return;
var role = roleSel.value;
if (!role) {
container.innerHTML = '<p style="color:var(--color-text-muted);">Selecciona un rol para ver y editar sus permisos.</p>';
return;
}
var current = _rolePermissions[role] || [];
var html = '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:var(--space-4);">';
_availablePermissions.forEach(function(group) {
html += '<div style="border:1px solid var(--color-border);border-radius:var(--radius-md);padding:var(--space-3);background:var(--color-surface-2);">';
html += '<h4 style="margin:0 0 var(--space-3);font-size:var(--text-body-sm);color:var(--color-text-primary);">' + escapeHtml(group.module) + '</h4>';
group.permissions.forEach(function(p) {
var checked = current.indexOf(p.key) !== -1 ? 'checked' : '';
html += '<label style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-2);cursor:pointer;font-size:var(--text-body-sm);">';
html += '<input type="checkbox" data-perm-key="' + escapeHtml(p.key) + '" ' + checked + ' style="width:auto;" />';
html += '<span>' + escapeHtml(p.label) + '</span>';
html += '</label>';
});
html += '</div>';
});
html += '</div>';
container.innerHTML = html;
}
async function saveRolePermissions() {
var roleSel = document.getElementById('cfg-perm-role');
var status = document.getElementById('role-permissions-status');
if (!roleSel || !roleSel.value) {
if (status) status.textContent = 'Selecciona un rol';
return;
}
var role = roleSel.value;
var selected = [];
document.querySelectorAll('#role-permissions-container input[data-perm-key]').forEach(function(cb) {
if (cb.checked) selected.push(cb.dataset.permKey);
});
var payload = { roles: {} };
payload.roles[role] = selected;
try {
var res = await fetch(API + '/role-permissions', {
method: 'PUT',
headers: headers(),
body: JSON.stringify(payload)
});
var data = await res.json().catch(function() { return { error: res.statusText }; });
if (!res.ok) throw new Error(data.error || 'Error al guardar');
_rolePermissions[role] = selected;
if (status) status.textContent = 'Permisos guardados y aplicados a empleados existentes';
setTimeout(function() { if (status) status.textContent = ''; }, 4000);
} catch (e) {
if (status) status.textContent = e.message;
toast(e.message, 'error');
}
}
// Workshop-specific permissions editor
// -------------------------------------------------------------------------
async function loadWorkshopPermissions() {
try {
var res = await fetch(API + '/role-permissions/workshop', { headers: headers() });
if (!res.ok) return;
var data = await res.json();
_workshopPermissions = data.roles || {};
_workshopSchema = { statuses: data.statuses || [], actions: data.actions || [] };
renderWorkshopPermissions();
} catch (e) {
console.error('Config.loadWorkshopPermissions:', e);
}
}
function renderWorkshopPermissions() {
var container = document.getElementById('workshop-permissions-container');
var roleSel = document.getElementById('cfg-perm-role');
if (!container || !roleSel) return;
var role = roleSel.value;
if (!role) {
container.innerHTML = '<p style="color:var(--color-text-muted);">Selecciona un rol para ver y editar sus permisos de Taller.</p>';
return;
}
var cfg = _workshopPermissions[role] || { statuses: [], actions: [] };
var visibleStatuses = cfg.statuses || [];
var visibleActions = cfg.actions || [];
var html = '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:var(--space-4);">';
// Statuses
html += '<div style="border:1px solid var(--color-border);border-radius:var(--radius-md);padding:var(--space-3);background:var(--color-surface-2);">';
html += '<h4 style="margin:0 0 var(--space-3);font-size:var(--text-body-sm);color:var(--color-text-primary);">Estatus visibles en Taller</h4>';
(_workshopSchema.statuses || []).forEach(function(s) {
var checked = visibleStatuses.indexOf(s.key) !== -1 ? 'checked' : '';
html += '<label style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-2);cursor:pointer;font-size:var(--text-body-sm);">';
html += '<input type="checkbox" data-ws-status="' + escapeHtml(s.key) + '" ' + checked + ' style="width:auto;" />';
html += '<span>' + escapeHtml(s.label) + '</span>';
html += '</label>';
});
html += '</div>';
// Actions
html += '<div style="border:1px solid var(--color-border);border-radius:var(--radius-md);padding:var(--space-3);background:var(--color-surface-2);">';
html += '<h4 style="margin:0 0 var(--space-3);font-size:var(--text-body-sm);color:var(--color-text-primary);">Acciones permitidas en Taller</h4>';
(_workshopSchema.actions || []).forEach(function(a) {
var checked = visibleActions.indexOf(a.key) !== -1 ? 'checked' : '';
html += '<label style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-2);cursor:pointer;font-size:var(--text-body-sm);">';
html += '<input type="checkbox" data-ws-action="' + escapeHtml(a.key) + '" ' + checked + ' style="width:auto;" />';
html += '<span>' + escapeHtml(a.label) + '</span>';
html += '</label>';
});
html += '</div>';
html += '</div>';
container.innerHTML = html;
}
async function saveWorkshopPermissions() {
var roleSel = document.getElementById('cfg-perm-role');
var status = document.getElementById('workshop-permissions-status');
if (!roleSel || !roleSel.value) {
if (status) status.textContent = 'Selecciona un rol';
return;
}
var role = roleSel.value;
var statuses = [];
var actions = [];
document.querySelectorAll('#workshop-permissions-container input[data-ws-status]').forEach(function(cb) {
if (cb.checked) statuses.push(cb.dataset.wsStatus);
});
document.querySelectorAll('#workshop-permissions-container input[data-ws-action]').forEach(function(cb) {
if (cb.checked) actions.push(cb.dataset.wsAction);
});
var payload = { roles: {} };
payload.roles[role] = { statuses: statuses, actions: actions };
try {
var res = await fetch(API + '/role-permissions/workshop', {
method: 'PUT',
headers: headers(),
body: JSON.stringify(payload)
});
var data = await res.json().catch(function() { return { error: res.statusText }; });
if (!res.ok) throw new Error(data.error || 'Error al guardar');
_workshopPermissions[role] = { statuses: statuses, actions: actions };
if (status) status.textContent = 'Permisos de Taller guardados';
setTimeout(function() { if (status) status.textContent = ''; }, 4000);
} catch (e) {
if (status) status.textContent = e.message;
toast(e.message, 'error');
}
}
function switchPermTab(tab) {
_currentPermTab = tab;
document.querySelectorAll('#perm-panel-modules, #perm-panel-workshop').forEach(function(el) { el.style.display = 'none'; el.classList.remove('active'); });
document.querySelectorAll('#tab-perm-modules, #tab-perm-workshop').forEach(function(el) { el.classList.remove('active'); });
document.getElementById('perm-panel-' + tab).style.display = 'block';
document.getElementById('perm-panel-' + tab).classList.add('active');
document.getElementById('tab-perm-' + tab).classList.add('active');
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Init // Init
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -1277,10 +830,6 @@ const Config = (() => {
// Bind UI events // Bind UI events
bindEvents(); bindEvents();
// Global save button
var btnSaveAll = document.getElementById('btn-save-all');
if (btnSaveAll) btnSaveAll.addEventListener('click', saveAll);
// Vehicle compat source save button // Vehicle compat source save button
var btnCompat = document.getElementById('btn-save-compat-source'); var btnCompat = document.getElementById('btn-save-compat-source');
if (btnCompat) { if (btnCompat) {
@@ -1308,11 +857,6 @@ const Config = (() => {
}); });
} }
// Show permissions tab only for owner/admin
var isAdmin = user.role === 'owner' || user.role === 'admin';
var permTabBtn = document.querySelector('.cfg-tab-btn--permissions');
if (permTabBtn) permTabBtn.style.display = isAdmin ? '' : 'none';
// Load real data in parallel // Load real data in parallel
loadBranches(); loadBranches();
loadEmployees(); loadEmployees();
@@ -1321,24 +865,18 @@ const Config = (() => {
loadVehicleCompatSource(); loadVehicleCompatSource();
loadAllowedBrands(); loadAllowedBrands();
loadModules(); loadModules();
loadSalesSettings();
loadReceiptConfig();
if (isAdmin) {
loadRolePermissions();
loadWorkshopPermissions();
}
// Activate default or stored tab
var defaultTab = 'general';
try {
var storedTab = localStorage.getItem('pos_config_tab');
if (storedTab) defaultTab = storedTab;
} catch(e) {}
switchTab(defaultTab);
} }
document.addEventListener('DOMContentLoaded', init); document.addEventListener('DOMContentLoaded', init);
return {
init, setTheme, selectThemeOption, loadAllowedBrands, saveAllowedBrands,
loadBranches, loadEmployees, saveBranch, saveEmployee, editEmployee,
loadBusiness, saveBusiness, saveTaxParams,
loadCurrency, saveCurrency,
loadModules, saveModules,
openModal, closeModal, openBranchModal, editBranch
};
// Register Cmd+K items // Register Cmd+K items
if (typeof registerCmdKItem === "function") { if (typeof registerCmdKItem === "function") {
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" }); registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
@@ -1347,18 +885,4 @@ const Config = (() => {
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" }); registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
} }
return {
init, setTheme, selectThemeOption, loadAllowedBrands, saveAllowedBrands,
loadBranches, loadEmployees, saveBranch, saveEmployee, editEmployee, deleteEmployee,
deleteBranch,
loadBusiness, saveBusiness, saveTaxParams, saveAll,
loadCurrency, saveCurrency,
loadVehicleCompatSource, saveVehicleCompatSource,
loadModules, saveModules,
loadReceiptConfig, saveReceiptConfig, handleReceiptLogo, removeReceiptLogo,
openModal, closeModal, openBranchModal, editBranch,
switchTab, loadRolePermissions, renderRolePermissions, saveRolePermissions,
loadWorkshopPermissions, renderWorkshopPermissions, saveWorkshopPermissions, switchPermTab
};
})(); })();

View File

@@ -5,20 +5,12 @@
* Wired to the design-system HTML (customers.html). * Wired to the design-system HTML (customers.html).
*/ */
const Customers = (() => { const Customers = (() => {
let token = localStorage.getItem('pos_token') || '';
let currentPage = 1; let currentPage = 1;
let totalPages = 1; let totalPages = 1;
let currentCustomer = null; let currentCustomer = null;
let searchTimeout = null; let searchTimeout = null;
const user = window.POS_USER || {};
const userRole = (user.role || '').toLowerCase();
const userPerms = user.permissions || [];
const canDeleteCustomer = userRole === 'owner' || userRole === 'admin' || userPerms.includes('customers.delete');
function getToken() {
return localStorage.getItem('pos_token') || '';
}
const fmt = (n) => '$' + parseFloat(n || 0).toLocaleString('es-MX', { const fmt = (n) => '$' + parseFloat(n || 0).toLocaleString('es-MX', {
minimumFractionDigits: 2, maximumFractionDigits: 2 minimumFractionDigits: 2, maximumFractionDigits: 2
}); });
@@ -31,7 +23,7 @@ const Customers = (() => {
}; };
function headers() { function headers() {
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + getToken() }; return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
} }
async function api(url, options = {}) { async function api(url, options = {}) {
@@ -51,9 +43,6 @@ const Customers = (() => {
const tierClass = { 1: 'mostrador', 2: 'taller', 3: 'mayoreo' }; const tierClass = { 1: 'mostrador', 2: 'taller', 3: 'mayoreo' };
function statusBadge(c) { function statusBadge(c) {
if (c.is_active === false) {
return '<span class="badge badge--inactive"><span class="badge-dot"></span>Inactivo</span>';
}
// Derive status: if credit_balance > credit_limit => Mora, else Activo // Derive status: if credit_balance > credit_limit => Mora, else Activo
if (c.credit_balance > 0 && c.credit_limit > 0 && c.credit_balance > c.credit_limit) { if (c.credit_balance > 0 && c.credit_limit > 0 && c.credit_balance > c.credit_limit) {
return '<span class="badge badge--warning"><span class="badge-dot"></span>Mora</span>'; return '<span class="badge badge--warning"><span class="badge-dot"></span>Mora</span>';
@@ -93,27 +82,14 @@ const Customers = (() => {
const searchEl = document.getElementById('searchInput'); const searchEl = document.getElementById('searchInput');
q = q !== undefined ? q : (searchEl ? searchEl.value || '' : ''); q = q !== undefined ? q : (searchEl ? searchEl.value || '' : '');
const tipoEl = document.getElementById('tipoFilter');
const estadoEl = document.getElementById('estadoFilter');
const tipo = tipoEl ? tipoEl.value : '';
// Map UI status labels to backend values
const estadoMap = { 'Activo': 'active', 'Inactivo': 'inactive', 'Mora': 'overdue' };
const estado = estadoEl ? (estadoMap[estadoEl.value] || 'all') : 'active';
const tbody = document.getElementById('customersBody');
if (tbody) tbody.innerHTML = '<tr><td colspan="11">' + renderLoadingState({ message: 'Cargando clientes...' }) + '</td></tr>';
try { try {
const params = new URLSearchParams({ page, per_page: 50 }); const params = new URLSearchParams({ page, per_page: 50 });
if (q) params.append('q', q); if (q) params.append('q', q);
if (tipo) params.append('price_tier', tipo);
if (estado) params.append('status', estado);
const data = await api(`/pos/api/customers?${params}`); const data = await api(`/pos/api/customers?${params}`);
renderTable(data.data || []); renderTable(data.data || []);
renderPagination(data.pagination || {}); renderPagination(data.pagination || {});
} catch (e) { } catch (e) {
if (tbody) tbody.innerHTML = '<tr><td colspan="11">' + renderEmptyState({ title: 'Error', subtitle: 'No se pudieron cargar los clientes.' }) + '</td></tr>';
console.error('Load customers failed:', e); console.error('Load customers failed:', e);
} }
} }
@@ -160,7 +136,7 @@ const Customers = (() => {
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg>', icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg>',
title: 'Sin clientes', title: 'Sin clientes',
subtitle: 'No se encontraron clientes registrados.', subtitle: 'No se encontraron clientes registrados.',
action: '<button class="btn btn--primary btn--sm" onclick="Customers.showCreateModal()">Nuevo cliente</button>' action: '<button class="btn btn--primary btn--sm" onclick="Customers.openCreateModal()">Nuevo cliente</button>'
}) + '</td></tr>'; }) + '</td></tr>';
return; return;
} }
@@ -303,28 +279,6 @@ const Customers = (() => {
const discountEl = document.getElementById('detailMaxDiscount'); const discountEl = document.getElementById('detailMaxDiscount');
if (discountEl) discountEl.textContent = (c.max_discount_pct || 0) + '%'; if (discountEl) discountEl.textContent = (c.max_discount_pct || 0) + '%';
// Vehicles
const vehiclesEl = document.getElementById('detailVehicles');
if (vehiclesEl) {
const vehicles = c.fleet_vehicles || [];
if (vehicles.length === 0) {
vehiclesEl.innerHTML = '<span style="color:var(--color-text-muted);">Sin veh&iacute;culos registrados</span>';
} else {
vehiclesEl.innerHTML = vehicles.map(v => {
const title = [v.year, v.make, v.model].filter(Boolean).join(' ');
const subtitle = [v.plate, v.vin, v.color].filter(Boolean).join(' · ');
return `<div class="vehicle-row" style="padding:var(--space-2);border:1px solid var(--color-border);border-radius:var(--radius-md);">
<div style="font-weight:600;">${title || 'Veh&iacute;culo'}</div>
<div style="font-size:var(--text-caption);color:var(--color-text-muted);">${subtitle}</div>
</div>`;
}).join('');
}
}
// Delete button visibility
const btnDelete = document.getElementById('btnDeleteCustomer');
if (btnDelete) btnDelete.style.display = canDeleteCustomer ? 'inline-flex' : 'none';
// Re-wire action buttons after detail panel is visible // Re-wire action buttons after detail panel is visible
wireActionButtons(); wireActionButtons();
@@ -426,106 +380,15 @@ const Customers = (() => {
// Wire action buttons in detail panel // Wire action buttons in detail panel
function wireActionButtons() { function wireActionButtons() {
const btns = document.querySelectorAll('.quick-actions .action-btn'); const btns = document.querySelectorAll('.quick-actions .action-btn');
// Order: Nueva Venta, Editar, Estado de Cuenta, Historial, Eliminar // Order: Nueva Venta, Editar, Estado de Cuenta, Historial
if (btns.length >= 1) btns[0].onclick = () => { if (btns.length >= 1) btns[0].onclick = () => {
if (currentCustomer) window.location.href = '/pos/sale?customer=' + currentCustomer.id; if (currentCustomer) window.location.href = '/pos/sale?customer=' + currentCustomer.id;
}; };
if (btns.length >= 2) btns[1].onclick = () => editCurrent(); if (btns.length >= 2) btns[1].onclick = () => editCurrent();
if (btns.length >= 3) btns[2].onclick = () => showStatement(); if (btns.length >= 3) btns[2].onclick = () => showStatement();
if (btns.length >= 4) btns[3].onclick = () => { if (btns.length >= 4) btns[3].onclick = () => {
if (currentCustomer) showCustomerHistory(currentCustomer.id); if (currentCustomer) selectCustomer(currentCustomer.id);
}; };
const btnDelete = document.getElementById('btnDeleteCustomer');
if (btnDelete) btnDelete.onclick = () => deleteCustomer();
}
async function deleteCustomer() {
if (!currentCustomer) return;
if (!canDeleteCustomer) {
alert('No tienes permiso para eliminar clientes');
return;
}
if (!confirm(`¿Eliminar al cliente "${currentCustomer.name}"? Se borrará completamente. Las ventas, órdenes y vehículos conservarán sus datos pero quedarán sin cliente asignado.`)) return;
try {
await api(`/pos/api/customers/${currentCustomer.id}`, { method: 'DELETE' });
alert('Cliente eliminado');
currentCustomer = null;
closeDetail();
loadCustomers(currentPage);
} catch (e) {
alert('Error: ' + e.message);
}
}
window.deleteCustomer = deleteCustomer;
async function showCustomerHistory(customerId) {
try {
const res = await api(`/pos/api/customers/${customerId}/purchases`);
const purchases = res.data || [];
let modal = document.getElementById('customerHistoryModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'customerHistoryModal';
modal.className = 'modal-overlay';
modal.style.display = 'none';
modal.innerHTML = `
<div class="modal-content" style="max-width:700px;">
<div class="modal-header">
<h3>Historial de Compras — <span id="customerHistoryName"></span></h3>
<button class="modal-close" onclick="Customers.closeCustomerHistoryModal()">&times;</button>
</div>
<div class="modal-body">
<table class="history-table" style="width:100%;">
<thead>
<tr>
<th>Fecha</th>
<th>Folio</th>
<th>Total</th>
<th>Pago</th>
<th>Estado</th>
</tr>
</thead>
<tbody id="customerHistoryBody"></tbody>
</table>
</div>
</div>
`;
document.body.appendChild(modal);
}
const tbody = document.getElementById('customerHistoryBody');
const title = document.getElementById('customerHistoryName');
if (title && currentCustomer) title.textContent = currentCustomer.name;
if (purchases.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--color-text-muted);padding:var(--space-4);">Sin compras registradas</td></tr>';
} else {
tbody.innerHTML = purchases.map(p => {
const statusClass = p.status === 'paid' ? 'mbadge--paid' : p.status === 'cancelled' ? 'mbadge--error' : p.status === 'overdue' ? 'mbadge--overdue' : 'mbadge--pending';
const statusLabel = p.status === 'paid' ? 'Pagado' : p.status === 'cancelled' ? 'Cancelado' : p.status === 'overdue' ? 'Vencido' : 'Pendiente';
return `<tr>
<td class="date">${formatDate(p.created_at)}</td>
<td class="folio">NX-${String(p.id).padStart(5, '0')}</td>
<td class="total">${fmt(p.total)}</td>
<td>${p.payment_method || '-'}</td>
<td><span class="mbadge ${statusClass}">${statusLabel}</span></td>
</tr>`;
}).join('');
}
modal.style.display = 'flex';
modal.classList.add('active');
} catch (e) {
console.error('Error loading customer history:', e);
alert('Error al cargar historial: ' + e.message);
}
}
function closeCustomerHistoryModal() {
const modal = document.getElementById('customerHistoryModal');
if (modal) {
modal.style.display = 'none';
modal.classList.remove('active');
}
} }
// ─── Create/Edit Modal ─────────────── // ─── Create/Edit Modal ───────────────
@@ -717,7 +580,7 @@ const Customers = (() => {
try { try {
await api(`/pos/api/customers/${currentCustomer.id}/payment`, { await api(`/pos/api/customers/${currentCustomer.id}/payment`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ amount, payment_method: method, reference }), body: JSON.stringify({ amount, method, reference }),
}); });
closePayment(); closePayment();
selectCustomer(currentCustomer.id); selectCustomer(currentCustomer.id);
@@ -736,7 +599,7 @@ const Customers = (() => {
// ─── Init ──────────────────────────── // ─── Init ────────────────────────────
function init() { function init() {
// Auth check // Auth check
if (!getToken()) { if (!token) {
window.location.href = '/pos/login'; window.location.href = '/pos/login';
return; return;
} }
@@ -956,18 +819,8 @@ const Customers = (() => {
showCreateModal, editCurrent, editCustomer, closeModal, save, showCreateModal, editCurrent, editCustomer, closeModal, save,
showStatement, closeStatement, showStatement, closeStatement,
showPaymentModal, closePayment, recordPayment, showPaymentModal, closePayment, recordPayment,
showCustomerHistory, closeCustomerHistoryModal,
deleteCustomer,
}; };
// Register Cmd+K items
if (typeof registerCmdKItem === "function") {
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
registerCmdKItem({ group: "Principal", label: "Catálogo", href: "/pos/catalog", icon: "📁" });
registerCmdKItem({ group: "Principal", label: "Clientes", href: "/pos/customers", icon: "👤" });
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
}
// Bulk selection // Bulk selection
publicApi.toggleCustomerSelection = function(id) { publicApi.toggleCustomerSelection = function(id) {
if (selectedCustomers.has(id)) selectedCustomers.delete(id); if (selectedCustomers.has(id)) selectedCustomers.delete(id);
@@ -1003,15 +856,15 @@ const Customers = (() => {
updateBulkToolbar(); updateBulkToolbar();
}; };
publicApi.featureProximamente = function(name) {
if (typeof window.featureProximamente === 'function') {
window.featureProximamente(name);
} else {
alert(name + ' — próximamente');
}
};
// Expose globally for inline HTML onclick handlers // Expose globally for inline HTML onclick handlers
window.Customers = publicApi; window.Customers = publicApi;
return publicApi; return publicApi;
// Register Cmd+K items
if (typeof registerCmdKItem === "function") {
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
registerCmdKItem({ group: "Principal", label: "Catálogo", href: "/pos/catalog", icon: "📁" });
registerCmdKItem({ group: "Principal", label: "Clientes", href: "/pos/customers", icon: "👤" });
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
}
})(); })();

View File

@@ -46,7 +46,7 @@ const Dashboard = (() => {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Theme switcher // Theme switcher
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
function setTheme(theme) { /*function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-theme', theme);
try { localStorage.setItem('pos_theme', theme); } catch(e) {} try { localStorage.setItem('pos_theme', theme); } catch(e) {}
const btnInd = document.getElementById('btn-industrial'); const btnInd = document.getElementById('btn-industrial');
@@ -54,7 +54,15 @@ const Dashboard = (() => {
if (btnInd) btnInd.classList.toggle('active', theme === 'industrial'); if (btnInd) btnInd.classList.toggle('active', theme === 'industrial');
if (btnMod) btnMod.classList.toggle('active', theme === 'modern'); if (btnMod) btnMod.classList.toggle('active', theme === 'modern');
} }
window.setTheme = setTheme; window.setTheme = setTheme;*/
function setTheme(theme) {
if (window.posSetTheme) window.posSetTheme(theme);
const btnInd = document.getElementById('btn-industrial');
const btnMod = document.getElementById('btn-modern');
if (btnInd) btnInd.classList.toggle('active', theme === 'industrial');
if (btnMod) btnMod.classList.toggle('active', theme === 'modern');
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Sidebar toggle (mobile) // Sidebar toggle (mobile)
@@ -367,51 +375,16 @@ const Dashboard = (() => {
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// 4. Credit alerts // 4. Top Products (from today's sales detail)
// -------------------------------------------------------------------------
async function loadCreditAlerts() {
const data = await apiFetch('/pos/api/dashboard/credit-alerts');
const tbody = document.getElementById('credit-alerts-tbody');
const meta = document.getElementById('credit-alerts-meta');
if (!tbody) return;
if (!data || !data.data || data.data.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:var(--space-4);color:var(--color-text-muted);">No hay créditos por vencer.</td></tr>';
if (meta) meta.textContent = 'Vencidos: 0 / Por vencer: 0';
return;
}
if (meta) {
meta.innerHTML = `<span style="color:var(--color-error);font-weight:600;">Vencidos: ${data.overdue_count || 0}</span> &nbsp;|&nbsp; <span style="color:var(--color-warning);font-weight:600;">Por vencer: ${data.due_soon_count || 0}</span>`;
}
tbody.innerHTML = data.data.map(function(r) {
const dueDate = r.due_date ? new Date(r.due_date).toLocaleDateString('es-MX') : '-';
const daysText = r.days_until_due < 0 ? `${Math.abs(r.days_until_due)} días vencido` : `${r.days_until_due} días restantes`;
const statusClass = r.status === 'overdue' ? 'error' : (r.status === 'due_soon' ? 'warning' : 'success');
return `<tr>
<td><span class="td-client">${escHtml(r.customer_name)}</span></td>
<td><span class="td-mono">${escHtml(r.folio)}</span></td>
<td>${dueDate}</td>
<td>${daysText}</td>
<td class="align-right"><span class="td-mono">${fmt(r.balance)}</span></td>
<td><span class="badge badge--${statusClass}">${r.status_label}</span></td>
</tr>`;
}).join('');
}
// -------------------------------------------------------------------------
// 5. Top Products (from today's sales detail)
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
async function loadTopProducts() { async function loadTopProducts() {
// Single optimized endpoint: returns today's top products already aggregated const today = todayStr();
const data = await apiFetch('/pos/api/dashboard/stats'); // Fetch all today's sales with pagination
const data = await apiFetch(`/pos/api/sales?date_from=${today}&date_to=${today}&status=completed&per_page=200`);
const container = document.getElementById('top-products-list'); const container = document.getElementById('top-products-list');
if (!container) return; if (!container) return;
const top = data && data.top_products ? data.top_products : []; if (!data || !data.data || data.data.length === 0) {
if (!top.length) {
container.innerHTML = renderEmptyState({ container.innerHTML = renderEmptyState({
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><path d="M8 21h8M12 17v4"/></svg>', icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><path d="M8 21h8M12 17v4"/></svg>',
title: 'Sin ventas hoy', title: 'Sin ventas hoy',
@@ -421,7 +394,37 @@ const Dashboard = (() => {
return; return;
} }
const sorted = top.slice(0, 5); // Fetch detail for each sale to get items (up to 20 sales for performance)
const salesToFetch = data.data.slice(0, 20);
const details = await Promise.all(
salesToFetch.map(s => apiFetch(`/pos/api/sales/${s.id}`))
);
// Aggregate items
const productMap = {};
for (const sale of details) {
if (!sale || !sale.items) continue;
for (const item of sale.items) {
const key = item.part_number || item.name;
if (!productMap[key]) {
productMap[key] = { name: item.name, part_number: item.part_number || '', qty: 0, revenue: 0 };
}
productMap[key].qty += item.quantity || 0;
productMap[key].revenue += item.subtotal || 0;
}
}
const sorted = Object.values(productMap).sort((a, b) => b.revenue - a.revenue).slice(0, 5);
if (sorted.length === 0) {
container.innerHTML = renderEmptyState({
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"/><line x1="3" y1="6" x2="21" y2="6"/><path d="M16 10a4 4 0 01-8 0"/></svg>',
title: 'Sin productos vendidos',
subtitle: 'No hay suficiente información para mostrar el ranking.'
});
return;
}
const maxRev = sorted[0].revenue || 1; const maxRev = sorted[0].revenue || 1;
container.innerHTML = sorted.map((p, i) => { container.innerHTML = sorted.map((p, i) => {
const pct = Math.round((p.revenue / maxRev) * 100); const pct = Math.round((p.revenue / maxRev) * 100);
@@ -430,7 +433,7 @@ const Dashboard = (() => {
<div class="rank-num ${i === 0 ? 'rank-num--1' : i === 1 ? 'rank-num--2' : ''}">${i + 1}</div> <div class="rank-num ${i === 0 ? 'rank-num--1' : i === 1 ? 'rank-num--2' : ''}">${i + 1}</div>
<div class="rank-item__info"> <div class="rank-item__info">
<div class="rank-item__name">${escHtml(p.name)}</div> <div class="rank-item__name">${escHtml(p.name)}</div>
<div class="rank-item__sub">${p.quantity} pzas vendidas</div> <div class="rank-item__sub">${escHtml(p.part_number)} &nbsp;&middot;&nbsp; ${p.qty} pzas vendidas</div>
<div class="rank-item__bar-bg"> <div class="rank-item__bar-bg">
<div class="rank-item__bar-fill" style="width:${pct}%"></div> <div class="rank-item__bar-fill" style="width:${pct}%"></div>
</div> </div>
@@ -624,12 +627,11 @@ const Dashboard = (() => {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
async function loadRecentSales() { async function loadRecentSales() {
const today = todayStr(); const today = todayStr();
const data = await apiFetch(`/pos/api/sales/recent?date_from=${today}&date_to=${today}&limit=10`); const data = await apiFetch(`/pos/api/sales?date_from=${today}&date_to=${today}&per_page=10`);
const tbody = document.getElementById('recent-sales-tbody'); const tbody = document.getElementById('recent-sales-tbody');
if (!tbody) return; if (!tbody) return;
const sales = data && data.data ? data.data : []; if (!data || !data.data || data.data.length === 0) {
if (!sales.length) {
tbody.innerHTML = '<tr><td colspan="5">' + renderEmptyState({ tbody.innerHTML = '<tr><td colspan="5">' + renderEmptyState({
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><path d="M8 21h8M12 17v4"/></svg>', icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><path d="M8 21h8M12 17v4"/></svg>',
title: 'Sin ventas hoy', title: 'Sin ventas hoy',
@@ -639,22 +641,26 @@ const Dashboard = (() => {
return; return;
} }
const salesToShow = sales.slice(0, 5); // Fetch items for first 5 sales
const salesToShow = data.data.slice(0, 5);
const details = await Promise.all(
salesToShow.map(s => apiFetch(`/pos/api/sales/${s.id}`))
);
tbody.innerHTML = salesToShow.map((sale) => { tbody.innerHTML = salesToShow.map((sale, idx) => {
const detail = details[idx];
const time = sale.created_at ? sale.created_at.slice(11, 16) : '--:--'; const time = sale.created_at ? sale.created_at.slice(11, 16) : '--:--';
const client = sale.customer_name || 'Publico General'; const client = sale.customer_name || 'Publico General';
const total = sale.total || 0; const total = sale.total || 0;
const method = sale.payment_method || 'efectivo'; const method = sale.payment_method || 'efectivo';
// Build products summary from items already included in the response // Build products summary from detail items
let productsSummary = ''; let productsSummary = '';
const items = sale.items || []; if (detail && detail.items && detail.items.length > 0) {
if (items.length > 0) { productsSummary = detail.items.slice(0, 3).map(it =>
productsSummary = items.slice(0, 3).map(it =>
`${escHtml(it.name)}${it.quantity > 1 ? ' (x' + it.quantity + ')' : ''}` `${escHtml(it.name)}${it.quantity > 1 ? ' (x' + it.quantity + ')' : ''}`
).join(', '); ).join(', ');
if (items.length > 3) productsSummary += '...'; if (detail.items.length > 3) productsSummary += '...';
} }
const methodClass = getPaymentBadgeClass(method); const methodClass = getPaymentBadgeClass(method);
@@ -703,7 +709,6 @@ const Dashboard = (() => {
loadDailySummary(); loadDailySummary();
loadHistoricalSummary(); loadHistoricalSummary();
loadAlerts(); loadAlerts();
loadCreditAlerts();
loadTopProducts(); loadTopProducts();
loadChart('semana'); loadChart('semana');
loadRecentSales(); loadRecentSales();
@@ -712,7 +717,6 @@ const Dashboard = (() => {
setInterval(() => { setInterval(() => {
loadDailySummary(); loadDailySummary();
loadRecentSales(); loadRecentSales();
loadCreditAlerts();
}, 120000); }, 120000);
} }

View File

@@ -12,16 +12,6 @@ var Fleet = (function() {
var currentPage = 1; var currentPage = 1;
var searchTimeout = null; var searchTimeout = null;
var user = window.POS_USER || {};
var role = (user.role || '').toLowerCase();
var perms = user.permissions || [];
function hasPerm(p) {
return role === 'owner' || perms.indexOf(p) !== -1;
}
var canCreate = hasPerm('fleet.create');
var canEdit = hasPerm('fleet.edit');
var canDelete = hasPerm('fleet.delete');
// ─── Helpers ─── // ─── Helpers ───
function headers() { function headers() {
@@ -85,16 +75,9 @@ var Fleet = (function() {
} }
// New vehicle button // New vehicle button
var btnNewVehicle = document.getElementById('btnNewVehicle'); document.getElementById('btnNewVehicle').addEventListener('click', function() {
if (btnNewVehicle) {
if (!canCreate) {
btnNewVehicle.style.display = 'none';
} else {
btnNewVehicle.addEventListener('click', function() {
openVehicleModal(); openVehicleModal();
}); });
}
}
// Load initial data // Load initial data
loadStats(); loadStats();
@@ -122,8 +105,6 @@ var Fleet = (function() {
var url = API + '/vehicles?page=' + currentPage + '&per_page=50'; var url = API + '/vehicles?page=' + currentPage + '&per_page=50';
if (q) url += '&q=' + encodeURIComponent(q); if (q) url += '&q=' + encodeURIComponent(q);
document.getElementById('vehicleGrid').innerHTML = renderLoadingState({ message: 'Cargando vehiculos...' });
fetch(url, {headers: headers()}) fetch(url, {headers: headers()})
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(d) { .then(function(d) {
@@ -134,7 +115,7 @@ var Fleet = (function() {
}) })
.catch(function() { .catch(function() {
document.getElementById('vehicleGrid').innerHTML = document.getElementById('vehicleGrid').innerHTML =
renderEmptyState({ title: 'Error', subtitle: 'No se pudieron cargar los vehiculos.' }); '<div class="empty-state"><div class="empty-state__text">Error al cargar vehiculos</div></div>';
}); });
} }
@@ -144,7 +125,7 @@ var Fleet = (function() {
grid.innerHTML = '<div class="empty-state">' + grid.innerHTML = '<div class="empty-state">' +
'<div class="empty-state__icon">&#x1F69A;</div>' + '<div class="empty-state__icon">&#x1F69A;</div>' +
'<div class="empty-state__text">No hay vehiculos registrados</div>' + '<div class="empty-state__text">No hay vehiculos registrados</div>' +
(canCreate ? '<button class="btn btn--primary" onclick="Fleet.openVehicleModal()">+ Agregar Vehiculo</button>' : '') + '<button class="btn btn--primary" onclick="Fleet.openVehicleModal()">+ Agregar Vehiculo</button>' +
'</div>'; '</div>';
return; return;
} }
@@ -154,7 +135,7 @@ var Fleet = (function() {
var label = (v.make || '') + ' ' + (v.model || ''); var label = (v.make || '') + ' ' + (v.model || '');
if (v.year) label += ' ' + v.year; if (v.year) label += ' ' + v.year;
html += '<div class="vehicle-card" ' + (canEdit ? 'onclick="Fleet.viewVehicle(' + v.id + ')" style="cursor:pointer;"' : '') + '>' + html += '<div class="vehicle-card" onclick="Fleet.viewVehicle(' + v.id + ')">' +
'<div class="vehicle-card__header">' + '<div class="vehicle-card__header">' +
'<span class="vehicle-card__plate">' + esc(v.plate || 'SIN PLACA') + '</span>' + '<span class="vehicle-card__plate">' + esc(v.plate || 'SIN PLACA') + '</span>' +
'<span class="badge ' + (v.is_active ? 'badge--active' : 'badge--inactive') + '">' + '<span class="badge ' + (v.is_active ? 'badge--active' : 'badge--inactive') + '">' +
@@ -219,11 +200,9 @@ var Fleet = (function() {
function openVehicleModal(data) { function openVehicleModal(data) {
var modal = document.getElementById('vehicleModal'); var modal = document.getElementById('vehicleModal');
var title = document.getElementById('vehicleModalTitle'); var title = document.getElementById('vehicleModalTitle');
var saveBtn = document.getElementById('btnSaveVehicle');
if (data && data.id) { if (data && data.id) {
title.textContent = 'Editar Vehiculo'; title.textContent = 'Editar Vehiculo';
if (saveBtn) saveBtn.style.display = canEdit ? '' : 'none';
document.getElementById('vehEditId').value = data.id; document.getElementById('vehEditId').value = data.id;
document.getElementById('vehPlate').value = data.plate || ''; document.getElementById('vehPlate').value = data.plate || '';
document.getElementById('vehVin').value = data.vin || ''; document.getElementById('vehVin').value = data.vin || '';
@@ -237,7 +216,6 @@ var Fleet = (function() {
document.getElementById('vehNotes').value = data.notes || ''; document.getElementById('vehNotes').value = data.notes || '';
} else { } else {
title.textContent = 'Nuevo Vehiculo'; title.textContent = 'Nuevo Vehiculo';
if (saveBtn) saveBtn.style.display = canCreate ? '' : 'none';
document.getElementById('vehEditId').value = ''; document.getElementById('vehEditId').value = '';
['vehPlate','vehVin','vehMake','vehModel','vehYear','vehColor','vehOwner','vehNotes'] ['vehPlate','vehVin','vehMake','vehModel','vehYear','vehColor','vehOwner','vehNotes']
.forEach(function(id) { document.getElementById(id).value = ''; }); .forEach(function(id) { document.getElementById(id).value = ''; });
@@ -254,8 +232,6 @@ var Fleet = (function() {
function saveVehicle() { function saveVehicle() {
var editId = document.getElementById('vehEditId').value; var editId = document.getElementById('vehEditId').value;
if (editId && !canEdit) { alert('No tienes permiso para editar vehiculos'); return; }
if (!editId && !canCreate) { alert('No tienes permiso para crear vehiculos'); return; }
var payload = { var payload = {
plate: document.getElementById('vehPlate').value.trim(), plate: document.getElementById('vehPlate').value.trim(),
vin: document.getElementById('vehVin').value.trim(), vin: document.getElementById('vehVin').value.trim(),
@@ -299,36 +275,26 @@ var Fleet = (function() {
// ─── Maintenance Tab ─── // ─── Maintenance Tab ───
function loadMaintenance() { function loadMaintenance() {
// Single bulk endpoint replaces N+1 per-vehicle schedule requests // Load all vehicles with their schedules
document.getElementById('maintBody').innerHTML = fetch(API + '/vehicles?per_page=200', {headers: headers()})
'<tr><td colspan="7" style="padding:var(--space-6);">' + renderLoadingState({ message: 'Cargando programas...' }) + '</td></tr>';
fetch(API + '/vehicles/schedules', {headers: headers()})
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(d) { .then(function(d) {
var schedules = d.data || []; var allVehicles = d.data || [];
var results = schedules.map(function(s) { var promises = allVehicles.map(function(v) {
return { return fetch(API + '/vehicles/' + v.id + '/schedules', {headers: headers()})
vehicle: s.vehicle || {}, .then(function(r) { return r.json(); })
schedules: [{ .then(function(s) {
id: s.id, return {vehicle: v, schedules: s.data || []};
maintenance_type: s.maintenance_type,
interval_km: s.interval_km,
interval_months: s.interval_months,
last_done_at: s.last_done_at,
last_done_km: s.last_done_km,
next_due_at: s.next_due_at,
next_due_km: s.next_due_km,
notes: s.notes
}]
};
}); });
});
return Promise.all(promises);
})
.then(function(results) {
renderMaintenance(results); renderMaintenance(results);
}) })
.catch(function(e) { .catch(function() {
console.error('loadMaintenance error:', e);
document.getElementById('maintBody').innerHTML = document.getElementById('maintBody').innerHTML =
'<tr><td colspan="7" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Error', subtitle: 'No se pudieron cargar los programas.' }) + '</td></tr>'; '<tr><td colspan="7" style="text-align:center;color:var(--color-text-muted);">Error al cargar</td></tr>';
}); });
} }
@@ -360,7 +326,7 @@ var Fleet = (function() {
'<td>' + (next || '—') + '</td>' + '<td>' + (next || '—') + '</td>' +
'<td><span class="badge ' + (isOverdue ? 'badge--overdue' : 'badge--active') + '">' + '<td><span class="badge ' + (isOverdue ? 'badge--overdue' : 'badge--active') + '">' +
(isOverdue ? 'Vencido' : 'Al dia') + '</span></td>' + (isOverdue ? 'Vencido' : 'Al dia') + '</span></td>' +
'<td>' + (canCreate ? '<button class="btn btn--sm btn--ghost" onclick="Fleet.openLogModalFor(' + v.id + ',' + s.id + ',\'' + esc(s.maintenance_type) + '\')">Registrar</button>' : '') + '</td>' + '<td><button class="btn btn--sm btn--ghost" onclick="Fleet.openLogModalFor(' + v.id + ',' + s.id + ',\'' + esc(s.maintenance_type) + '\')">Registrar</button></td>' +
'</tr>'; '</tr>';
} }
@@ -378,9 +344,7 @@ var Fleet = (function() {
if (!items.length) { if (!items.length) {
body.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">' + body.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">' +
'No hay programas de mantenimiento.<br>' + 'No hay programas de mantenimiento.<br><button class="btn btn--primary btn--sm" style="margin-top:var(--space-3);" onclick="Fleet.openScheduleModal()">+ Crear Programa</button></td></tr>';
(canCreate ? '<button class="btn btn--primary btn--sm" style="margin-top:var(--space-3);" onclick="Fleet.openScheduleModal()">+ Crear Programa</button>' : '') +
'</td></tr>';
return; return;
} }
@@ -399,25 +363,25 @@ var Fleet = (function() {
// ─── History Tab ─── // ─── History Tab ───
function loadHistory() { function loadHistory() {
// Single bulk endpoint replaces N+1 per-vehicle detail requests fetch(API + '/vehicles?per_page=200', {headers: headers()})
document.getElementById('historyBody').innerHTML =
'<tr><td colspan="7" style="padding:var(--space-6);">' + renderLoadingState({ message: 'Cargando historial...' }) + '</td></tr>';
fetch(API + '/vehicles/history', {headers: headers()})
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(d) { .then(function(d) {
var logs = d.data || []; var allVehicles = d.data || [];
var results = logs.map(function(l) { var promises = allVehicles.map(function(v) {
l._plate = (l.vehicle && l.vehicle.plate) || 'S/P'; return fetch(API + '/vehicles/' + v.id, {headers: headers()})
l._make = ((l.vehicle && l.vehicle.make) || '') + ' ' + ((l.vehicle && l.vehicle.model) || ''); .then(function(r) { return r.json(); })
return {vehicle: l.vehicle || {}, logs: [l]}; .then(function(detail) {
return {vehicle: v, logs: detail.recent_logs || []};
}); });
});
return Promise.all(promises);
})
.then(function(results) {
renderHistory(results); renderHistory(results);
}) })
.catch(function(e) { .catch(function() {
console.error('loadHistory error:', e);
document.getElementById('historyBody').innerHTML = document.getElementById('historyBody').innerHTML =
'<tr><td colspan="7" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Error', subtitle: 'No se pudo cargar el historial.' }) + '</td></tr>'; '<tr><td colspan="7" style="text-align:center;color:var(--color-text-muted);">Error al cargar</td></tr>';
}); });
} }
@@ -473,8 +437,6 @@ var Fleet = (function() {
// ─── Alerts Tab ─── // ─── Alerts Tab ───
function loadAlerts() { function loadAlerts() {
document.getElementById('alertsList').innerHTML = renderLoadingState({ message: 'Cargando alertas...' });
fetch(API + '/alerts', {headers: headers()}) fetch(API + '/alerts', {headers: headers()})
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(d) { .then(function(d) {
@@ -482,7 +444,7 @@ var Fleet = (function() {
}) })
.catch(function() { .catch(function() {
document.getElementById('alertsList').innerHTML = document.getElementById('alertsList').innerHTML =
renderEmptyState({ title: 'Error', subtitle: 'No se pudieron cargar las alertas.' }); '<div class="empty-state"><div class="empty-state__text">Error al cargar alertas</div></div>';
}); });
} }
@@ -513,7 +475,7 @@ var Fleet = (function() {
' &nbsp; ' + detail + ' &nbsp; ' + detail +
'</div>' + '</div>' +
'</div>' + '</div>' +
(canCreate ? '<button class="btn btn--sm btn--primary" onclick="Fleet.openLogModalFor(' + a.vehicle_id + ',' + a.schedule_id + ',\'' + esc(a.maintenance_type) + '\')">Registrar Mant.</button>' : '') + '<button class="btn btn--sm btn--primary" onclick="Fleet.openLogModalFor(' + a.vehicle_id + ',' + a.schedule_id + ',\'' + esc(a.maintenance_type) + '\')">Registrar Mant.</button>' +
'</div>'; '</div>';
}); });
el.innerHTML = html; el.innerHTML = html;
@@ -542,7 +504,6 @@ var Fleet = (function() {
} }
function saveSchedule() { function saveSchedule() {
if (!canCreate) { alert('No tienes permiso para crear programas'); return; }
var vehicleId = document.getElementById('schedVehicleSelect').value; var vehicleId = document.getElementById('schedVehicleSelect').value;
if (!vehicleId) { alert('Seleccione un vehiculo'); return; } if (!vehicleId) { alert('Seleccione un vehiculo'); return; }
@@ -613,7 +574,6 @@ var Fleet = (function() {
} }
function saveLog() { function saveLog() {
if (!canCreate) { alert('No tienes permiso para registrar mantenimiento'); return; }
var vehicleId = document.getElementById('logVehicleSelect').value; var vehicleId = document.getElementById('logVehicleSelect').value;
if (!vehicleId) { alert('Seleccione un vehiculo'); return; } if (!vehicleId) { alert('Seleccione un vehiculo'); return; }

View File

@@ -10,7 +10,6 @@ var I18N = {
'catalog': 'Catalogo', 'catalog': 'Catalogo',
'inventory': 'Inventario', 'inventory': 'Inventario',
'diagrams': 'Diagramas', 'diagrams': 'Diagramas',
'remission_notes': 'Notas de Remisión',
'customers': 'Clientes', 'customers': 'Clientes',
'invoicing': 'Facturacion', 'invoicing': 'Facturacion',
'accounting': 'Contabilidad', 'accounting': 'Contabilidad',
@@ -167,7 +166,6 @@ var I18N = {
'catalog': 'Catalog', 'catalog': 'Catalog',
'inventory': 'Inventory', 'inventory': 'Inventory',
'diagrams': 'Diagrams', 'diagrams': 'Diagrams',
'remission_notes': 'Remission Notes',
'customers': 'Customers', 'customers': 'Customers',
'invoicing': 'Invoicing', 'invoicing': 'Invoicing',
'accounting': 'Accounting', 'accounting': 'Accounting',
@@ -319,9 +317,7 @@ var I18N = {
} }
}; };
// Spanish is mandatory for all tenants; ignore any previously stored language. var currentLang = localStorage.getItem('pos_lang') || 'es';
var currentLang = 'es';
localStorage.setItem('pos_lang', 'es');
/** /**
* Translate a key to the current language. * Translate a key to the current language.
@@ -332,11 +328,12 @@ window.t = function(key) {
}; };
/** /**
* Language switcher is disabled; the system stays in Spanish. * Switch the UI language and reload.
*/ */
window.setLang = function(lang) { window.setLang = function(lang) {
currentLang = 'es'; currentLang = lang;
localStorage.setItem('pos_lang', 'es'); localStorage.setItem('pos_lang', lang);
location.reload();
}; };
/** /**

View File

@@ -6,38 +6,19 @@
'use strict'; 'use strict';
var API = '/pos/api/inventory'; var API = '/pos/api/inventory';
function getToken() { return localStorage.getItem('pos_token') || ''; } var token = localStorage.getItem('pos_token');
if (!getToken()) { window.location.href = '/pos/login'; return; } if (!token) { window.location.href = '/pos/login'; return; }
function authHeaders() { return { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' }; } var headers = { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
var currentPage = 1; var currentPage = 1;
var currentSearch = ''; var currentSearch = '';
var draftCountId = null; var draftCountId = null;
var inventoryVS = null; var inventoryVS = null;
var compatSource = 'both'; // default, loaded from config var compatSource = 'both'; // default, loaded from config
var inventorySearchController = null;
var user = window.POS_USER || {};
var userRole = (user.role || '').toLowerCase();
var userPerms = user.permissions || [];
var canEditPrices = userRole === 'owner' || userRole === 'admin' || userPerms.indexOf('config.edit_prices') !== -1;
var canCreateItem = userRole === 'owner' || userRole === 'admin' || userRole === 'counter' || userRole === 'cashier' || userRole === 'warehouse' || userPerms.indexOf('inventory.create') !== -1;
var canEditItem = userRole === 'owner' || userRole === 'admin' || userRole === 'warehouse' || userPerms.indexOf('inventory.edit') !== -1;
var canImportItems = userRole === 'owner' || userRole === 'admin' || userRole === 'warehouse' || userPerms.indexOf('inventory.edit') !== -1;
// Hide toolbar actions the user is not allowed to use
(function applyInventoryPermissions() {
var headerNew = document.getElementById('btnHeaderNewProduct');
var stockNew = document.getElementById('btnStockNewProduct');
var headerImport = document.getElementById('btnHeaderImport');
if (headerNew) headerNew.style.display = canCreateItem ? '' : 'none';
if (stockNew) stockNew.style.display = canCreateItem ? '' : 'none';
if (headerImport) headerImport.style.display = canImportItems ? '' : 'none';
})();
// Load compatibility source setting // Load compatibility source setting
(function loadCompatSource() { (function loadCompatSource() {
fetch('/pos/api/config/vehicle-compat-source', { headers: { 'Authorization': 'Bearer ' + getToken() } }) fetch('/pos/api/config/vehicle-compat-source', { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(d) { .then(function(d) {
if (d.source) compatSource = d.source; if (d.source) compatSource = d.source;
@@ -46,7 +27,7 @@
// --- API helper --- // --- API helper ---
function apiFetch(url, opts) { function apiFetch(url, opts) {
return fetch(url, Object.assign({ headers: authHeaders() }, opts || {})) return fetch(url, Object.assign({ headers: headers }, opts || {}))
.then(function (resp) { .then(function (resp) {
if (resp.status === 401) { if (resp.status === 401) {
localStorage.removeItem('pos_token'); localStorage.removeItem('pos_token');
@@ -65,15 +46,6 @@
d.textContent = s; d.textContent = s;
return d.innerHTML; return d.innerHTML;
} }
function formatDateTime(isoStr) {
if (!isoStr) return '-';
var d = new Date(isoStr);
if (isNaN(d.getTime())) return esc(isoStr);
return d.toLocaleString('es-MX', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
});
}
// --- Dashboard summary badges --- // --- Dashboard summary badges ---
function loadSummary() { function loadSummary() {
@@ -128,7 +100,7 @@
var d3 = parseFloat(document.getElementById('tierDisc3').value) || 0; var d3 = parseFloat(document.getElementById('tierDisc3').value) || 0;
fetch(API + '/tier-discounts', { fetch(API + '/tier-discounts', {
method: 'PUT', method: 'PUT',
headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' }, headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ discount_pct_2: d2, discount_pct_3: d3 }) body: JSON.stringify({ discount_pct_2: d2, discount_pct_3: d3 })
}).then(function(r) { return r.json(); }) }).then(function(r) { return r.json(); })
.then(function(res) { .then(function(res) {
@@ -199,11 +171,10 @@
'<td>' + esc(it.location) + '</td>' + '<td>' + esc(it.location) + '</td>' +
'<td>' + '<td>' +
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();viewHistory(' + it.id + ')">Historial</button> ' + '<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();viewHistory(' + it.id + ')">Historial</button> ' +
(canEditItem ? '<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();showEditItemModal(' + it.id + ')">Editar</button> ' : '') + '<button class="btn btn--ghost btn--sm" style="color:var(--color-accent);" onclick="event.stopPropagation();showPurchaseModalForItem(' + it.id + ')">Entrada</button> ' +
(canCreateItem ? '<button class="btn btn--ghost btn--sm" style="color:var(--color-accent);" onclick="event.stopPropagation();showPurchaseModalForItem(' + it.id + ')">Entrada</button> ' : '') + '<button class="btn btn--sm btn--meli" onclick="event.stopPropagation();publishToMeli(' + it.id + ')">ML</button> ' +
(userPerms.indexOf('marketplace.manage') !== -1 || userRole === 'owner' || userRole === 'admin' ? '<button class="btn btn--sm btn--meli" onclick="event.stopPropagation();publishToMeli(' + it.id + ')">ML</button> ' : '') +
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();printBarcode(\'' + esc(it.barcode) + '\',\'' + esc(it.part_number) + '\',\'' + esc(it.name) + '\')">Etiqueta</button> ' + '<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();printBarcode(\'' + esc(it.barcode) + '\',\'' + esc(it.part_number) + '\',\'' + esc(it.name) + '\')">Etiqueta</button> ' +
(canEditItem ? '<button class="btn btn--ghost btn--sm" style="color:var(--color-error);" onclick="event.stopPropagation();deleteItem(' + it.id + ')">Eliminar</button>' : '') + '<button class="btn btn--ghost btn--sm" style="color:var(--color-error);" onclick="event.stopPropagation();deleteItem(' + it.id + ')">Eliminar</button>' +
'</td></tr>'; '</td></tr>';
} }
@@ -254,14 +225,7 @@
var tbody = document.getElementById('productTableBody'); var tbody = document.getElementById('productTableBody');
if (tbody) tbody.innerHTML = renderSkeletonRows(12, 8); if (tbody) tbody.innerHTML = renderSkeletonRows(12, 8);
if (inventorySearchController) { apiFetch(API + '/items?' + params.toString()).then(function (data) {
inventorySearchController.abort();
}
inventorySearchController = new AbortController();
apiFetch(API + '/items?' + params.toString(), { signal: inventorySearchController.signal })
.then(function (data) {
inventorySearchController = null;
if (!data) return; if (!data) return;
var items = data.data || []; var items = data.data || [];
@@ -304,9 +268,6 @@
} else { } else {
pgEl.innerHTML = '<span style="font-size:var(--text-body-sm);color:var(--color-text-muted);">' + (pg.total || 0) + ' productos</span>'; pgEl.innerHTML = '<span style="font-size:var(--text-body-sm);color:var(--color-text-muted);">' + (pg.total || 0) + ' productos</span>';
} }
}).catch(function (err) {
if (err.name === 'AbortError') return;
console.error('Inventory load error:', err);
}); });
} }
@@ -326,49 +287,39 @@
// CREATE ITEM (createModal) // CREATE ITEM (createModal)
// ===================================================================== // =====================================================================
function loadCategories(selectedId) { function loadCategories() {
var sel = document.getElementById('newCategory'); var sel = document.getElementById('newCategory');
if (!sel) return; if (!sel) return;
apiFetch(API + '/categories/all').then(function(data) { apiFetch(API + '/categories').then(function(data) {
if (!data || !data.categories) return; if (!data || !data.categories) return;
var cats = data.categories; sel.innerHTML = '<option value="">Selecciona categoría</option>';
var top = cats.filter(function(c) { return !c.parent_id; }); data.categories.forEach(function(c) {
var subs = cats.filter(function(c) { return c.parent_id; }); sel.innerHTML += '<option value="' + c.id + '">' + esc(c.name) + '</option>';
sel.innerHTML = '<option value="">Sin categoría</option>';
top.forEach(function(c) {
sel.innerHTML += '<optgroup label="' + esc(c.name) + '">' +
'<option value="' + c.id + '"' + (c.id === selectedId ? ' selected' : '') + '>' + esc(c.name) + '</option>';
subs.filter(function(s) { return s.parent_id === c.id; }).forEach(function(s) {
sel.innerHTML += '<option value="' + s.id + '"' + (s.id === selectedId ? ' selected' : '') + '>&nbsp;&nbsp;' + esc(s.name) + '</option>';
});
sel.innerHTML += '</optgroup>';
}); });
}); });
} }
window.loadCategories = loadCategories; window.loadCategories = loadCategories;
function onCategoryChange(categoryId) { function onCategoryChange(categoryId) {
// Kept for backwards compatibility; selector is now flat. var subSel = document.getElementById('newSubcategory');
if (!subSel) return;
if (!categoryId) {
subSel.innerHTML = '<option value="">Selecciona categoría primero</option>';
subSel.disabled = true;
return;
}
apiFetch(API + '/categories/' + categoryId + '/subcategories').then(function(data) {
if (!data || !data.subcategories) return;
subSel.innerHTML = '<option value="">Selecciona subcategoría</option>';
data.subcategories.forEach(function(s) {
subSel.innerHTML += '<option value="' + s.id + '">' + esc(s.name) + '</option>';
});
subSel.disabled = false;
});
} }
window.onCategoryChange = onCategoryChange; window.onCategoryChange = onCategoryChange;
function resetCreateModal() {
document.getElementById('editItemId').value = '';
document.getElementById('createModalTitle').textContent = 'Nuevo Producto';
document.getElementById('createModalBtn').textContent = 'Crear Producto';
var ids = ['newPartNumber','newName','newBrand','newBarcode','newSku2','newSku3','newUnit','newCost','newPrice1','newMinStock','newInitialStock','newMaxStock','newTaxRate','newLocation','newDescription'];
ids.forEach(function(id) {
var el = document.getElementById(id);
if (el) el.value = '';
});
document.getElementById('newCategory').innerHTML = '<option value="">Sin categoría</option>';
document.getElementById('newIsActive').value = 'true';
document.getElementById('initialStockField').style.display = '';
document.querySelectorAll('#createModal .price-field input').forEach(function(el) { el.disabled = false; });
}
function showCreateModal() { function showCreateModal() {
resetCreateModal();
document.getElementById('createModal').classList.add('is-open'); document.getElementById('createModal').classList.add('is-open');
loadCategories(); loadCategories();
// Attach AI classification on part number blur // Attach AI classification on part number blur
@@ -386,41 +337,6 @@
} }
} }
function showEditItemModal(itemId) {
resetCreateModal();
document.getElementById('editItemId').value = itemId;
document.getElementById('createModalTitle').textContent = 'Editar Producto';
document.getElementById('createModalBtn').textContent = 'Guardar Cambios';
document.getElementById('initialStockField').style.display = 'none';
document.getElementById('createModal').classList.add('is-open');
apiFetch(API + '/items/' + itemId).then(function(it) {
if (!it) return;
document.getElementById('newPartNumber').value = it.part_number || '';
document.getElementById('newName').value = it.name || '';
document.getElementById('newBrand').value = it.brand || '';
document.getElementById('newBarcode').value = it.barcode || '';
document.getElementById('newUnit').value = it.unit || '';
document.getElementById('newCost').value = it.cost != null ? it.cost : '';
document.getElementById('newPrice1').value = it.price_1 != null ? it.price_1 : '';
document.getElementById('newMinStock').value = it.min_stock != null ? it.min_stock : '';
document.getElementById('newMaxStock').value = it.max_stock != null ? it.max_stock : '';
document.getElementById('newTaxRate').value = it.tax_rate != null ? it.tax_rate : '';
document.getElementById('newLocation').value = it.location || '';
document.getElementById('newDescription').value = it.description || '';
document.getElementById('newIsActive').value = (it.is_active === false ? 'false' : 'true');
document.getElementById('newSku2').value = (it.sku_aliases && it.sku_aliases[0]) ? it.sku_aliases[0].sku : '';
document.getElementById('newSku3').value = (it.sku_aliases && it.sku_aliases[1]) ? it.sku_aliases[1].sku : '';
if (!canEditPrices) {
document.querySelectorAll('#createModal .price-field input').forEach(function(el) { el.disabled = true; });
}
loadCategories(it.category_id);
}).catch(function(e) {
alert('Error al cargar producto: ' + e.message);
closeCreateModal();
});
}
window.showEditItemModal = showEditItemModal;
function classifyPartNumber(partNumber) { function classifyPartNumber(partNumber) {
var resultEl = document.getElementById('createResult'); var resultEl = document.getElementById('createResult');
resultEl.innerHTML = '<span style="color:var(--color-text-muted);">Consultando IA...</span>'; resultEl.innerHTML = '<span style="color:var(--color-text-muted);">Consultando IA...</span>';
@@ -451,57 +367,58 @@
function closeCreateModal() { function closeCreateModal() {
document.getElementById('createModal').classList.remove('is-open'); document.getElementById('createModal').classList.remove('is-open');
document.getElementById('createResult').innerHTML = ''; document.getElementById('createResult').innerHTML = '';
resetCreateModal(); var catSel = document.getElementById('newCategory');
var subSel = document.getElementById('newSubcategory');
if (catSel) catSel.innerHTML = '<option value="">Selecciona categoría</option>';
if (subSel) { subSel.innerHTML = '<option value="">Selecciona categoría primero</option>'; subSel.disabled = true; }
} }
function createItem() { function createItem() {
var editId = document.getElementById('editItemId').value; var elPrice2 = document.getElementById('newPrice2');
var elPrice3 = document.getElementById('newPrice3');
var data = { var data = {
part_number: document.getElementById('newPartNumber').value.trim(), part_number: document.getElementById('newPartNumber').value.trim(),
name: document.getElementById('newName').value.trim(), name: document.getElementById('newName').value.trim(),
brand: document.getElementById('newBrand').value.trim(), brand: document.getElementById('newBrand').value.trim(),
barcode: document.getElementById('newBarcode').value.trim() || undefined, barcode: document.getElementById('newBarcode').value.trim() || undefined,
unit: document.getElementById('newUnit').value.trim() || undefined,
cost: parseFloat(document.getElementById('newCost').value) || 0, cost: parseFloat(document.getElementById('newCost').value) || 0,
price_1: parseFloat(document.getElementById('newPrice1').value) || 0, price_1: parseFloat(document.getElementById('newPrice1').value) || 0,
price_2: elPrice2 ? (parseFloat(elPrice2.value) || 0) : 0,
price_3: elPrice3 ? (parseFloat(elPrice3.value) || 0) : 0,
min_stock: parseInt(document.getElementById('newMinStock').value) || 0, min_stock: parseInt(document.getElementById('newMinStock').value) || 0,
max_stock: parseInt(document.getElementById('newMaxStock').value) || 0, initial_stock: parseInt(document.getElementById('newInitialStock').value) || 0,
tax_rate: parseFloat(document.getElementById('newTaxRate').value) || 0,
location: document.getElementById('newLocation').value.trim(), location: document.getElementById('newLocation').value.trim(),
description: document.getElementById('newDescription').value.trim(),
is_active: document.getElementById('newIsActive').value === 'true',
sku_aliases: [] sku_aliases: []
}; };
var sku2 = document.getElementById('newSku2').value.trim(); var sku2 = document.getElementById('newSku2').value.trim();
var sku3 = document.getElementById('newSku3').value.trim(); var sku3 = document.getElementById('newSku3').value.trim();
var categoryId = document.getElementById('newCategory').value; var categoryId = document.getElementById('newCategory').value;
if (categoryId) data.category_id = parseInt(categoryId); var subcategoryId = document.getElementById('newSubcategory').value;
if (sku2) data.sku_aliases.push({sku: sku2, label: 'Alternativo 1'}); if (sku2) data.sku_aliases.push({sku: sku2, label: 'Alternativo 1'});
if (sku3) data.sku_aliases.push({sku: sku3, label: 'Alternativo 2'}); if (sku3) data.sku_aliases.push({sku: sku3, label: 'Alternativo 2'});
if (subcategoryId) {
data.category_id = parseInt(subcategoryId);
} else if (categoryId) {
data.category_id = parseInt(categoryId);
}
if (!data.part_number || !data.name) { if (!data.part_number || !data.name) {
document.getElementById('createResult').innerHTML = '<span style="color:var(--color-error);">Numero de parte y nombre son obligatorios</span>'; document.getElementById('createResult').innerHTML = '<span style="color:var(--color-error);">Numero de parte y nombre son obligatorios</span>';
return; return;
} }
apiFetch(API + '/items', { method: 'POST', body: JSON.stringify(data) }).then(function (result) {
var url = API + '/items'; if (result && result.id) {
var method = 'POST'; var msg = 'Creado ID ' + result.id + ' | Barcode: ' + result.barcode;
if (editId) { if (result.vehicle_compatibilities_added > 0) {
url = API + '/items/' + editId;
method = 'PUT';
// Stock inicial solo en creación
} else {
data.initial_stock = parseInt(document.getElementById('newInitialStock').value) || 0;
}
apiFetch(url, { method: method, body: JSON.stringify(data) }).then(function (result) {
if (result && (result.id || result.message)) {
var msg = editId ? 'Producto actualizado' : ('Creado ID ' + result.id + ' | Barcode: ' + result.barcode);
if (!editId && result.vehicle_compatibilities_added > 0) {
msg += ' | ' + result.vehicle_compatibilities_added + ' vehiculo(s) asignado(s) por IA'; msg += ' | ' + result.vehicle_compatibilities_added + ' vehiculo(s) asignado(s) por IA';
} }
document.getElementById('createResult').innerHTML = '<span style="color:var(--color-success);">' + msg + '</span>'; document.getElementById('createResult').innerHTML = '<span style="color:var(--color-success);">' + msg + '</span>';
loadItems(currentPage); loadItems(currentPage);
// Close modal, clear form, refresh badges
closeCreateModal(); closeCreateModal();
['newPartNumber','newName','newBrand','newBarcode','newSku2','newSku3','newCost','newPrice1','newMinStock','newInitialStock','newLocation'].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.value = '';
});
if (window.loadInventoryStats) window.loadInventoryStats(); if (window.loadInventoryStats) window.loadInventoryStats();
} else { } else {
document.getElementById('createResult').innerHTML = '<span style="color:var(--color-error);">' + (result ? result.error || 'Error' : 'Error de red') + '</span>'; document.getElementById('createResult').innerHTML = '<span style="color:var(--color-error);">' + (result ? result.error || 'Error' : 'Error de red') + '</span>';
@@ -527,7 +444,7 @@
fetch(API + '/items/bulk-import', { fetch(API + '/items/bulk-import', {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': 'Bearer ' + getToken(), 'Authorization': 'Bearer ' + token,
'X-Import-Mode': mode, 'X-Import-Mode': mode,
'X-Import-Strategy': strategy 'X-Import-Strategy': strategy
}, },
@@ -557,28 +474,6 @@
} }
window.submitBulkImport = submitBulkImport; window.submitBulkImport = submitBulkImport;
function downloadBulkImportTemplate() {
var headers = [
'numero_de_parte','nombre','marca','precio','cantidad','costo',
'sku_secundario','descripcion','categoria','fabricante','modelo','anio','motor','codigo_motor'
];
var example = [
'EJ-001','Filtro de aceite','ACDelco','150.00','10','90.00',
'EJ001-ALT','Filtro para sedan','Filtros','Nissan','Sentra','2020','1.8','MR18DE'
];
var csv = [headers.join(','), example.join(',')].join('\n');
var blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'plantilla_inventario.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
window.downloadBulkImportTemplate = downloadBulkImportTemplate;
// ===================================================================== // =====================================================================
// PURCHASE / ENTRADA (purchaseModal) // PURCHASE / ENTRADA (purchaseModal)
// ===================================================================== // =====================================================================
@@ -1109,7 +1004,7 @@
history.forEach(function (h) { history.forEach(function (h) {
var qtyColor = h.quantity > 0 ? 'var(--color-success)' : 'var(--color-error)'; var qtyColor = h.quantity > 0 ? 'var(--color-success)' : 'var(--color-error)';
html += '<tr>' + html += '<tr>' +
'<td style="font-size:var(--text-caption);">' + formatDateTime(h.date) + '</td>' + '<td style="font-size:var(--text-caption);">' + esc(h.date) + '</td>' +
'<td>' + esc(h.type) + '</td>' + '<td>' + esc(h.type) + '</td>' +
'<td style="color:' + qtyColor + ';font-weight:600;">' + (h.quantity > 0 ? '+' : '') + h.quantity + '</td>' + '<td style="color:' + qtyColor + ';font-weight:600;">' + (h.quantity > 0 ? '+' : '') + h.quantity + '</td>' +
'<td class="td--amount">' + (h.cost ? '$' + fmt(h.cost) : '—') + '</td>' + '<td class="td--amount">' + (h.cost ? '$' + fmt(h.cost) : '—') + '</td>' +
@@ -1134,9 +1029,10 @@
function deleteItem(itemId) { function deleteItem(itemId) {
if (!confirm('¿Eliminar este artículo del inventario? Se mantendrán los registros históricos.')) return; if (!confirm('¿Eliminar este artículo del inventario? Se mantendrán los registros históricos.')) return;
var token = localStorage.getItem('pos_token') || '';
fetch(API + '/items/' + itemId, { fetch(API + '/items/' + itemId, {
method: 'DELETE', method: 'DELETE',
headers: getToken() ? { 'Authorization': 'Bearer ' + getToken() } : {} headers: token ? { 'Authorization': 'Bearer ' + token } : {}
}).then(function(r) { return r.json(); }) }).then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
if (data.error) { showToast(data.error, 'error', { title: 'Error al eliminar' }); return; } if (data.error) { showToast(data.error, 'error', { title: 'Error al eliminar' }); return; }
@@ -1189,7 +1085,7 @@
var ids = Array.from(selectedItems); var ids = Array.from(selectedItems);
fetch('/pos/api/marketplace-ext/inventory-check', { fetch('/pos/api/marketplace-ext/inventory-check', {
method: 'POST', method: 'POST',
headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' }, headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ inventory_ids: ids }) body: JSON.stringify({ inventory_ids: ids })
}).then(function(r){ return r.json(); }) }).then(function(r){ return r.json(); })
.then(function(data) { .then(function(data) {
@@ -1244,7 +1140,7 @@
formData.append('file', file); formData.append('file', file);
fetch('/pos/api/inventory/items/' + itemId + '/image', { fetch('/pos/api/inventory/items/' + itemId + '/image', {
method: 'POST', method: 'POST',
headers: { 'Authorization': 'Bearer ' + getToken() }, headers: { 'Authorization': 'Bearer ' + token },
body: formData body: formData
}).then(function(r){ return r.json(); }) }).then(function(r){ return r.json(); })
.then(function(data) { .then(function(data) {
@@ -1269,7 +1165,7 @@
clearTimeout(meliCategorySearchTimeout); clearTimeout(meliCategorySearchTimeout);
resultsDiv.innerHTML = '<div class="meli-cat-dropdown"><div class="meli-cat-loading">Buscando...</div></div>'; resultsDiv.innerHTML = '<div class="meli-cat-dropdown"><div class="meli-cat-loading">Buscando...</div></div>';
meliCategorySearchTimeout = setTimeout(function() { meliCategorySearchTimeout = setTimeout(function() {
fetch('/pos/api/marketplace-ext/categories?q=' + encodeURIComponent(q), { headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' } }) fetch('/pos/api/marketplace-ext/categories?q=' + encodeURIComponent(q), { headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' } })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
var cats = data.categories || []; var cats = data.categories || [];
@@ -1316,7 +1212,7 @@
var section = document.getElementById('meliAttrsSection'); var section = document.getElementById('meliAttrsSection');
grid.innerHTML = '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Cargando atributos...</p>'; grid.innerHTML = '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Cargando atributos...</p>';
section.style.display = 'block'; section.style.display = 'block';
fetch('/pos/api/marketplace-ext/categories/' + encodeURIComponent(categoryId) + '/attributes', { headers: { 'Authorization': 'Bearer ' + getToken() } }) fetch('/pos/api/marketplace-ext/categories/' + encodeURIComponent(categoryId) + '/attributes', { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r){ return r.json(); }) .then(function(r){ return r.json(); })
.then(function(data) { .then(function(data) {
meliCategoryAttrs = data.attributes || []; meliCategoryAttrs = data.attributes || [];
@@ -1445,7 +1341,7 @@
resultEl.innerHTML = '<span style="color:var(--color-text-muted);">Validando con MercadoLibre...</span>'; resultEl.innerHTML = '<span style="color:var(--color-text-muted);">Validando con MercadoLibre...</span>';
fetch('/pos/api/marketplace-ext/listings/validate', { fetch('/pos/api/marketplace-ext/listings/validate', {
method: 'POST', method: 'POST',
headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' }, headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
inventory_ids: ids, inventory_ids: ids,
category_id: categoryId, category_id: categoryId,
@@ -1535,7 +1431,7 @@
var maxAttempts = 60; // 2 min var maxAttempts = 60; // 2 min
var interval = setInterval(function() { var interval = setInterval(function() {
attempts++; attempts++;
fetch('/pos/api/marketplace-ext/listings/async/' + encodeURIComponent(taskId), { headers: { 'Authorization': 'Bearer ' + getToken() } }) fetch('/pos/api/marketplace-ext/listings/async/' + encodeURIComponent(taskId), { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r){ return r.json(); }) .then(function(r){ return r.json(); })
.then(function(data) { .then(function(data) {
if (data.status === 'done') { if (data.status === 'done') {
@@ -1572,7 +1468,7 @@
resultEl.innerHTML = '<span style="color:var(--color-text-muted);">' + (useAsync ? 'Encolando ' : 'Publicando ') + ids.length + ' producto(s)...</span>'; resultEl.innerHTML = '<span style="color:var(--color-text-muted);">' + (useAsync ? 'Encolando ' : 'Publicando ') + ids.length + ' producto(s)...</span>';
fetch(endpoint, { fetch(endpoint, {
method: 'POST', method: 'POST',
headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' }, headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
inventory_ids: ids, inventory_ids: ids,
category_id: categoryId, category_id: categoryId,
@@ -1631,7 +1527,7 @@
if (statusEl) statusEl.textContent = 'Subiendo...'; if (statusEl) statusEl.textContent = 'Subiendo...';
fetch(API + '/items/' + itemId + '/image', { fetch(API + '/items/' + itemId + '/image', {
method: 'POST', method: 'POST',
headers: { 'Authorization': 'Bearer ' + getToken() }, headers: { 'Authorization': 'Bearer ' + token },
body: fd body: fd
}) })
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
@@ -1654,7 +1550,7 @@
if (!confirm('Eliminar imagen de este producto?')) return; if (!confirm('Eliminar imagen de este producto?')) return;
fetch(API + '/items/' + itemId + '/image', { fetch(API + '/items/' + itemId + '/image', {
method: 'DELETE', method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + getToken() } headers: { 'Authorization': 'Bearer ' + token }
}) })
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
.then(function (result) { .then(function (result) {
@@ -1764,7 +1660,7 @@
? '/pos/api/catalog/part/' + catalogPartId ? '/pos/api/catalog/part/' + catalogPartId
: '/pos/api/catalog/search?q=' + encodeURIComponent(partNumber); : '/pos/api/catalog/search?q=' + encodeURIComponent(partNumber);
fetch(url, { headers: { 'Authorization': 'Bearer ' + getToken() } }) fetch(url, { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(d) { .then(function(d) {
var el = document.getElementById('crossRefContent'); var el = document.getElementById('crossRefContent');
@@ -1776,7 +1672,7 @@
// If it was a search, get alternatives from first result // If it was a search, get alternatives from first result
if (!catalogPartId && d.data && d.data.length > 0) { if (!catalogPartId && d.data && d.data.length > 0) {
// Fetch detail for first match // Fetch detail for first match
fetch('/pos/api/catalog/part/' + d.data[0].id_part, { headers: { 'Authorization': 'Bearer ' + getToken() } }) fetch('/pos/api/catalog/part/' + d.data[0].id_part, { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r2) { return r2.json(); }) .then(function(r2) { return r2.json(); })
.then(function(d2) { .then(function(d2) {
renderCrossRefs(el, d2.alternatives || [], d2.bodegas || []); renderCrossRefs(el, d2.alternatives || [], d2.bodegas || []);
@@ -1849,7 +1745,7 @@
// Load SKU aliases // Load SKU aliases
(function loadSkuAliases() { (function loadSkuAliases() {
fetch('/pos/api/inventory/items/' + itemId + '/skus', { headers: { 'Authorization': 'Bearer ' + getToken() } }) fetch('/pos/api/inventory/items/' + itemId + '/skus', { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(d) { .then(function(d) {
var el = document.getElementById('skuAliasContent'); var el = document.getElementById('skuAliasContent');
@@ -1881,7 +1777,7 @@
// Load vehicle compatibilities and makes // Load vehicle compatibilities and makes
(function loadCompatPanel() { (function loadCompatPanel() {
fetch('/pos/api/inventory/items/' + itemId + '/vehicles', { headers: { 'Authorization': 'Bearer ' + getToken() } }) fetch('/pos/api/inventory/items/' + itemId + '/vehicles', { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(d) { .then(function(d) {
var el = document.getElementById('compatContent'); var el = document.getElementById('compatContent');
@@ -1907,7 +1803,7 @@
}); });
// Load makes // Load makes
fetch('/pos/api/inventory/vehicles/makes', { headers: { 'Authorization': 'Bearer ' + getToken() } }) fetch('/pos/api/inventory/vehicles/makes', { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(d) { .then(function(d) {
var sel = document.getElementById('manualMake'); var sel = document.getElementById('manualMake');
@@ -1932,7 +1828,7 @@
history.forEach(function (h) { history.forEach(function (h) {
var qtyColor = h.quantity > 0 ? 'var(--color-success)' : 'var(--color-error)'; var qtyColor = h.quantity > 0 ? 'var(--color-success)' : 'var(--color-error)';
html += '<tr>' + html += '<tr>' +
'<td style="font-size:var(--text-caption);">' + formatDateTime(h.date) + '</td>' + '<td style="font-size:var(--text-caption);">' + esc(h.date) + '</td>' +
'<td>' + esc(h.type) + '</td>' + '<td>' + esc(h.type) + '</td>' +
'<td style="color:' + qtyColor + ';font-weight:600;">' + (h.quantity > 0 ? '+' : '') + h.quantity + '</td>' + '<td style="color:' + qtyColor + ';font-weight:600;">' + (h.quantity > 0 ? '+' : '') + h.quantity + '</td>' +
'<td class="td--amount">' + (h.cost ? '$' + fmt(h.cost) : '\u2014') + '</td>' + '<td class="td--amount">' + (h.cost ? '$' + fmt(h.cost) : '\u2014') + '</td>' +
@@ -1951,7 +1847,7 @@
function autoMatchCompat(itemId) { function autoMatchCompat(itemId) {
fetch('/pos/api/inventory/items/' + itemId + '/vehicles/auto-match', { fetch('/pos/api/inventory/items/' + itemId + '/vehicles/auto-match', {
method: 'POST', method: 'POST',
headers: { 'Authorization': 'Bearer ' + getToken() } headers: { 'Authorization': 'Bearer ' + token }
}).then(function(r) { return r.json(); }) }).then(function(r) { return r.json(); })
.then(function(d) { .then(function(d) {
var msg = ''; var msg = '';
@@ -1974,7 +1870,7 @@
if (!confirm('Quitar compatibilidad con este vehiculo?')) return; if (!confirm('Quitar compatibilidad con este vehiculo?')) return;
fetch('/pos/api/inventory/items/' + itemId + '/vehicles/' + compatId, { fetch('/pos/api/inventory/items/' + itemId + '/vehicles/' + compatId, {
method: 'DELETE', method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + getToken() } headers: { 'Authorization': 'Bearer ' + token }
}).then(function(r) { return r.json(); }) }).then(function(r) { return r.json(); })
.then(function() { .then(function() {
viewProductDetail(itemId); viewProductDetail(itemId);
@@ -2007,7 +1903,7 @@
modelSel.innerHTML = '<option value="">Selecciona marca</option>'; modelSel.innerHTML = '<option value="">Selecciona marca</option>';
return; return;
} }
fetch('/pos/api/inventory/vehicles/models?brand_id=' + brandId, { headers: { 'Authorization': 'Bearer ' + getToken() } }) fetch('/pos/api/inventory/vehicles/models?brand_id=' + brandId, { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(d) { .then(function(d) {
var opts = '<option value="">Selecciona modelo</option>'; var opts = '<option value="">Selecciona modelo</option>';
@@ -2033,7 +1929,7 @@
yearSel.innerHTML = '<option value="">Selecciona modelo</option>'; yearSel.innerHTML = '<option value="">Selecciona modelo</option>';
return; return;
} }
fetch('/pos/api/inventory/vehicles/years?model_id=' + modelId, { headers: { 'Authorization': 'Bearer ' + getToken() } }) fetch('/pos/api/inventory/vehicles/years?model_id=' + modelId, { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(d) { .then(function(d) {
var opts = '<option value="">Selecciona ano</option>'; var opts = '<option value="">Selecciona ano</option>';
@@ -2059,7 +1955,7 @@
engineSel.innerHTML = '<option value="">Selecciona ano</option>'; engineSel.innerHTML = '<option value="">Selecciona ano</option>';
return; return;
} }
fetch('/pos/api/inventory/vehicles/engines?model_id=' + modelId + '&year_id=' + yearId, { headers: { 'Authorization': 'Bearer ' + getToken() } }) fetch('/pos/api/inventory/vehicles/engines?model_id=' + modelId + '&year_id=' + yearId, { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(d) { .then(function(d) {
var opts = '<option value="">Selecciona motor</option>'; var opts = '<option value="">Selecciona motor</option>';
@@ -2087,7 +1983,7 @@
} }
fetch('/pos/api/inventory/items/' + itemId + '/vehicles/manual', { fetch('/pos/api/inventory/items/' + itemId + '/vehicles/manual', {
method: 'POST', method: 'POST',
headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' }, headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ make: make, model: model, year: parseInt(year), engine: engine, engine_code: engineCode }) body: JSON.stringify({ make: make, model: model, year: parseInt(year), engine: engine, engine_code: engineCode })
}).then(function(r) { return r.json(); }) }).then(function(r) { return r.json(); })
.then(function(d) { .then(function(d) {
@@ -2105,7 +2001,7 @@
if (!sku) { alert('Ingresa un SKU'); return; } if (!sku) { alert('Ingresa un SKU'); return; }
fetch('/pos/api/inventory/items/' + itemId + '/skus', { fetch('/pos/api/inventory/items/' + itemId + '/skus', {
method: 'POST', method: 'POST',
headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' }, headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ sku: sku, label: label }) body: JSON.stringify({ sku: sku, label: label })
}).then(function(r) { return r.json(); }) }).then(function(r) { return r.json(); })
.then(function(d) { .then(function(d) {
@@ -2118,7 +2014,7 @@
if (!confirm('Eliminar este SKU alternativo?')) return; if (!confirm('Eliminar este SKU alternativo?')) return;
fetch('/pos/api/inventory/items/' + itemId + '/skus/' + aliasId, { fetch('/pos/api/inventory/items/' + itemId + '/skus/' + aliasId, {
method: 'DELETE', method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + getToken() } headers: { 'Authorization': 'Bearer ' + token }
}).then(function(r) { return r.json(); }) }).then(function(r) { return r.json(); })
.then(function() { .then(function() {
viewProductDetail(itemId); viewProductDetail(itemId);

View File

@@ -62,10 +62,7 @@ const Invoicing = (() => {
if (name === 'notas') loadNotas(); if (name === 'notas') loadNotas();
if (name === 'complementos') loadComplementos(); if (name === 'complementos') loadComplementos();
if (name === 'cancelaciones') loadCancelaciones(); if (name === 'cancelaciones') loadCancelaciones();
if (name === 'config') { if (name === 'config') loadFacturapiStatus();
loadFacturapiStatus();
loadEmisorData();
}
} }
// ---- Badge helpers ---- // ---- Badge helpers ----
@@ -89,34 +86,17 @@ const Invoicing = (() => {
} }
// ---- Facturas (Tab 1) — loads from CFDI queue with type=Ingreso ---- // ---- Facturas (Tab 1) — loads from CFDI queue with type=Ingreso ----
let facturasCache = [];
async function loadFacturas() { async function loadFacturas() {
const panel = document.getElementById('panel-facturas'); const panel = document.getElementById('panel-facturas');
if (!panel) return; if (!panel) return;
const tbody = panel.querySelector('.data-table tbody'); const tbody = panel.querySelector('.data-table tbody');
if (!tbody) return; if (!tbody) return;
const statusFilter = document.getElementById('facturas-status-filter');
const status = statusFilter ? statusFilter.value : '';
const url = status ? `/queue?per_page=50&type=ingreso&status=${status}` : '/queue?per_page=50&type=ingreso';
tbody.innerHTML = '<tr><td colspan="10" style="padding:var(--space-6);">' + renderLoadingState({ message: 'Cargando facturas...' }) + '</td></tr>';
try { try {
const res = await api(url); const res = await api('/queue?per_page=50&type=Ingreso');
facturasCache = res.data || []; const items = res.data || [];
renderFacturas(facturasCache, res.pagination?.total || facturasCache.length);
} catch (e) {
tbody.innerHTML = '<tr><td colspan="10" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Error', subtitle: e.message }) + '</td></tr>';
}
}
function renderFacturas(items, total) {
const panel = document.getElementById('panel-facturas');
const tbody = panel.querySelector('.data-table tbody');
if (!items.length) { if (!items.length) {
tbody.innerHTML = '<tr><td colspan="10" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Sin facturas', subtitle: 'No hay facturas en este periodo.' }) + '</td></tr>'; tbody.innerHTML = '<tr><td colspan="10" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">No hay facturas en este periodo.</td></tr>';
return; return;
} }
tbody.innerHTML = items.map(item => `<tr> tbody.innerHTML = items.map(item => `<tr>
@@ -125,9 +105,9 @@ const Invoicing = (() => {
<td class="td--primary">${item.customer_name || '-'}</td> <td class="td--primary">${item.customer_name || '-'}</td>
<td class="td--mono">${item.rfc || '-'}</td> <td class="td--mono">${item.rfc || '-'}</td>
<td class="td--amount">$${fmt(item.subtotal)}</td> <td class="td--amount">$${fmt(item.subtotal)}</td>
<td class="td--amount">$${fmt(item.tax_total)}</td> <td class="td--amount">$${fmt(item.tax)}</td>
<td class="td--amount">$${fmt(item.total)}</td> <td class="td--amount">$${fmt(item.total)}</td>
<td style="font-size:var(--text-caption);">${item.payment_method || '-'}</td> <td style="font-size:var(--text-caption);">${item.uso_cfdi || '-'}</td>
<td>${statusBadge(item.status)}</td> <td>${statusBadge(item.status)}</td>
<td> <td>
<div style="display:flex;gap:4px;"> <div style="display:flex;gap:4px;">
@@ -138,42 +118,12 @@ const Invoicing = (() => {
</td> </td>
</tr>`).join(''); </tr>`).join('');
// Update footer count
const footer = panel.querySelector('.table-footer span'); const footer = panel.querySelector('.table-footer span');
if (footer) footer.textContent = `Mostrando 1\u2013${items.length} de ${total} facturas`; if (footer) footer.textContent = `Mostrando 1\u2013${items.length} de ${res.pagination?.total || items.length} facturas`;
} catch (e) {
tbody.innerHTML = `<tr><td colspan="10" style="color:var(--color-error);padding:var(--space-4);">Error: ${e.message}</td></tr>`;
} }
function filterFacturas() {
const q = (document.getElementById('facturas-search')?.value || '').toLowerCase();
const filtered = facturasCache.filter(item =>
(item.provisional_folio || '').toLowerCase().includes(q) ||
(item.customer_name || '').toLowerCase().includes(q) ||
(item.rfc || '').toLowerCase().includes(q)
);
renderFacturas(filtered, facturasCache.length);
}
function exportFacturasCSV() {
if (typeof window.exportVisibleTableCSV === 'function') {
window.exportVisibleTableCSV('facturas');
} else {
alert('Exportador no disponible');
}
}
function exportNotasCSV() {
if (typeof window.exportVisibleTableCSV === 'function') {
window.exportVisibleTableCSV('notas_de_credito');
} else {
alert('Exportador no disponible');
}
}
function newCreditNote() {
alert('Nueva nota de crédito — próximamente');
}
function newPaymentComplement() {
alert('Nuevo complemento de pago — próximamente');
} }
// ---- Notas de Credito (Tab 2) — loads from CFDI queue with type=Egreso ---- // ---- Notas de Credito (Tab 2) — loads from CFDI queue with type=Egreso ----
@@ -183,20 +133,18 @@ const Invoicing = (() => {
const tbody = panel.querySelector('.data-table tbody'); const tbody = panel.querySelector('.data-table tbody');
if (!tbody) return; if (!tbody) return;
tbody.innerHTML = '<tr><td colspan="7" style="padding:var(--space-6);">' + renderLoadingState({ message: 'Cargando notas de credito...' }) + '</td></tr>';
try { try {
const res = await api('/queue?per_page=50&type=egreso'); const res = await api('/queue?per_page=50&type=Egreso');
const items = res.data || []; const items = res.data || [];
if (!items.length) { if (!items.length) {
tbody.innerHTML = '<tr><td colspan="7" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Sin notas de credito', subtitle: 'No hay notas de credito registradas.' }) + '</td></tr>'; tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">No hay notas de credito.</td></tr>';
return; return;
} }
tbody.innerHTML = items.map(item => `<tr> tbody.innerHTML = items.map(item => `<tr>
<td class="td--mono">${item.provisional_folio || '-'}</td> <td class="td--mono">${item.provisional_folio || '-'}</td>
<td class="td--mono" style="color:var(--color-text-accent);">${item.related_folio || '-'}</td> <td class="td--mono" style="color:var(--color-text-accent);">${item.related_folio || '-'}</td>
<td class="td--primary">${item.customer_name || '-'}</td> <td class="td--primary">${item.customer_name || '-'}</td>
<td>${item.cancel_motive || '-'}</td> <td>${item.description || '-'}</td>
<td class="td--amount">$${fmt(item.total)}</td> <td class="td--amount">$${fmt(item.total)}</td>
<td>${statusBadge(item.status)}</td> <td>${statusBadge(item.status)}</td>
<td> <td>
@@ -207,7 +155,7 @@ const Invoicing = (() => {
</td> </td>
</tr>`).join(''); </tr>`).join('');
} catch (e) { } catch (e) {
tbody.innerHTML = '<tr><td colspan="7" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Error', subtitle: e.message }) + '</td></tr>'; tbody.innerHTML = `<tr><td colspan="7" style="color:var(--color-error);padding:var(--space-4);">Error: ${e.message}</td></tr>`;
} }
} }
@@ -218,31 +166,14 @@ const Invoicing = (() => {
const tbody = panel.querySelector('.data-table tbody'); const tbody = panel.querySelector('.data-table tbody');
if (!tbody) return; if (!tbody) return;
tbody.innerHTML = '<tr><td colspan="8" style="padding:var(--space-6);">' + renderLoadingState({ message: 'Cargando complementos...' }) + '</td></tr>';
try { try {
const res = await api('/queue?per_page=50&type=pago'); const res = await api('/queue?per_page=50&type=Pago');
complementosCache = res.data || []; const items = res.data || [];
renderComplementos(complementosCache); if (!items.length) {
} catch (e) { tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">No hay complementos de pago.</td></tr>';
tbody.innerHTML = '<tr><td colspan="8" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Error', subtitle: e.message }) + '</td></tr>';
}
}
let complementosCache = [];
function renderComplementos(items) {
const panel = document.getElementById('panel-complementos');
const tbody = panel.querySelector('.data-table tbody');
const methodFilter = document.getElementById('complementos-method-filter');
const method = methodFilter ? methodFilter.value : '';
const filtered = method ? items.filter(item => (item.payment_method || '').startsWith(method)) : items;
if (!filtered.length) {
tbody.innerHTML = '<tr><td colspan="8" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Sin complementos', subtitle: 'No hay complementos de pago registrados.' }) + '</td></tr>';
return; return;
} }
tbody.innerHTML = filtered.map(item => `<tr> tbody.innerHTML = items.map(item => `<tr>
<td class="td--mono">${item.provisional_folio || '-'}</td> <td class="td--mono">${item.provisional_folio || '-'}</td>
<td class="td--mono" style="color:var(--color-text-accent);">${item.related_folio || '-'}</td> <td class="td--mono" style="color:var(--color-text-accent);">${item.related_folio || '-'}</td>
<td class="td--primary">${item.customer_name || '-'}</td> <td class="td--primary">${item.customer_name || '-'}</td>
@@ -257,6 +188,9 @@ const Invoicing = (() => {
</div> </div>
</td> </td>
</tr>`).join(''); </tr>`).join('');
} catch (e) {
tbody.innerHTML = `<tr><td colspan="8" style="color:var(--color-error);padding:var(--space-4);">Error: ${e.message}</td></tr>`;
}
} }
// ---- Cancelaciones (Tab 4) — loads cancelled/cancelling CFDIs ---- // ---- Cancelaciones (Tab 4) — loads cancelled/cancelling CFDIs ----
@@ -354,18 +288,10 @@ const Invoicing = (() => {
let pendingHtml = ''; let pendingHtml = '';
if (status.pending_steps && status.pending_steps.length) { if (status.pending_steps && status.pending_steps.length) {
pendingHtml = '<ul style="margin:var(--space-2) 0 0 0;padding-left:var(--space-5);color:var(--color-warning);">' + pendingHtml = '<ul style="margin:var(--space-2) 0 0 0;padding-left:var(--space-5);color:var(--color-warning);">' +
status.pending_steps.map(s => `<li>${s.description || s.type || s}</li>`).join('') + status.pending_steps.map(s => `<li>${s.description || s.type}</li>`).join('') +
'</ul>'; '</ul>';
} }
const configuredHtml = status.configured
? '<span style="color:var(--color-success);">Sí</span>'
: '<span style="color:var(--color-error);">No</span>';
const retryButton = !status.configured || status.error
? '<button class="btn btn--secondary" style="margin-top:var(--space-3);" onclick="Invoicing.setupFacturapi(this)">Reintentar configuración</button>'
: '';
container.innerHTML = ` container.innerHTML = `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-4);"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-4);">
<div> <div>
@@ -375,128 +301,29 @@ const Invoicing = (() => {
<div style="font-family:var(--font-mono);font-size:var(--text-caption);color:var(--color-text-muted);">${status.org_id || ''}</div> <div style="font-family:var(--font-mono);font-size:var(--text-caption);color:var(--color-text-muted);">${status.org_id || ''}</div>
</div> </div>
<div> <div>
<div style="font-size:var(--text-caption);color:var(--color-text-muted);">Configurada</div> <div style="font-size:var(--text-caption);color:var(--color-text-muted);">CSD</div>
<div style="font-weight:var(--font-weight-semibold);">${configuredHtml}</div>
<div style="font-size:var(--text-caption);color:var(--color-text-muted);margin-top:var(--space-2);">CSD</div>
<div style="font-weight:var(--font-weight-semibold);">${csdHtml}</div> <div style="font-weight:var(--font-weight-semibold);">${csdHtml}</div>
<div style="font-size:var(--text-caption);color:var(--color-text-muted);margin-top:var(--space-2);">Pasos pendientes</div> <div style="font-size:var(--text-caption);color:var(--color-text-muted);margin-top:var(--space-2);">Pasos pendientes</div>
${pendingHtml || '<span style="color:var(--color-success);">Ninguno</span>'} ${pendingHtml || '<span style="color:var(--color-success);">Ninguno</span>'}
</div> </div>
</div> </div>
${status.error ? `<p style="color:var(--color-error);margin-top:var(--space-3);">Error: ${escapeHtml(status.error)}</p>` : ''} ${status.error ? `<p style="color:var(--color-error);margin-top:var(--space-3);">Error: ${escapeHtml(status.error)}</p>` : ''}
${retryButton}
`; `;
} catch (e) { } catch (e) {
container.innerHTML = `<p style="color:var(--color-error);">Error: ${e.message}</p>`; container.innerHTML = `<p style="color:var(--color-error);">Error: ${e.message}</p>`;
} }
} }
// ---- Emisor data (config tab) ----
async function loadEmisorData() {
const rfcEl = document.getElementById('rfc-emisor');
const cpEl = document.getElementById('cp-fiscal');
const razonEl = document.getElementById('razon-social');
const regimenEl = document.getElementById('regimen-fiscal');
const direccionEl = document.getElementById('direccion-fiscal');
const exteriorEl = document.getElementById('numero-exterior');
const interiorEl = document.getElementById('numero-interior');
const coloniaEl = document.getElementById('colonia-fiscal');
const ciudadEl = document.getElementById('ciudad-fiscal');
const municipioEl = document.getElementById('municipio-fiscal');
const estadoEl = document.getElementById('estado-fiscal');
if (!rfcEl || !cpEl || !razonEl || !regimenEl) return;
try {
const res = await fetch('/pos/api/config/business', { headers: headers() });
if (!res.ok) throw new Error('Error al cargar datos fiscales');
const data = await res.json();
rfcEl.value = data.rfc || '';
cpEl.value = data.cp || '';
razonEl.value = data.razon_social || '';
if (direccionEl) direccionEl.value = data.direccion || '';
if (exteriorEl) exteriorEl.value = data.numero_exterior || '';
if (interiorEl) interiorEl.value = data.numero_interior || '';
if (coloniaEl) coloniaEl.value = data.colonia || '';
if (ciudadEl) ciudadEl.value = data.ciudad || '';
if (municipioEl) municipioEl.value = data.municipio || '';
if (estadoEl) estadoEl.value = data.estado || '';
const regimen = data.regimen_fiscal || '601';
regimenEl.value = regimen + ' — ' + (regimenEl.querySelector('option[value^="' + regimen + '"')?.textContent.split('—')[1]?.trim() || '');
// If exact value not matched, leave first option selected by SAT code prefix
if (!regimenEl.value.startsWith(regimen)) {
Array.from(regimenEl.options).forEach(function(opt) {
if (opt.value.startsWith(regimen)) opt.selected = true;
});
}
} catch (e) {
console.error('Invoicing.loadEmisorData:', e);
}
}
async function saveEmisorData() {
const rfc = (document.getElementById('rfc-emisor')?.value || '').trim();
const cp = (document.getElementById('cp-fiscal')?.value || '').trim();
const razon_social = (document.getElementById('razon-social')?.value || '').trim();
const regimenValue = document.getElementById('regimen-fiscal')?.value || '601';
const regimen_fiscal = regimenValue.split('—')[0].trim();
const direccion = (document.getElementById('direccion-fiscal')?.value || '').trim();
const numero_exterior = (document.getElementById('numero-exterior')?.value || '').trim();
const numero_interior = (document.getElementById('numero-interior')?.value || '').trim();
const colonia = (document.getElementById('colonia-fiscal')?.value || '').trim();
const ciudad = (document.getElementById('ciudad-fiscal')?.value || '').trim();
const municipio = (document.getElementById('municipio-fiscal')?.value || '').trim();
const estado = (document.getElementById('estado-fiscal')?.value || '').trim();
const statusEl = document.getElementById('emisor-save-status');
if (!rfc || !razon_social || !cp) {
if (statusEl) statusEl.textContent = 'RFC, Razón Social y C.P. son obligatorios';
return;
}
try {
const res = await fetch('/pos/api/config/business', {
method: 'PUT',
headers: headers(),
body: JSON.stringify({
rfc, razon_social, regimen_fiscal, cp,
direccion, numero_exterior, numero_interior, colonia, ciudad, municipio, estado,
cfdi_regimen_fiscal: regimen_fiscal, cfdi_serie: 'A'
})
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || 'Error al guardar');
}
if (statusEl) {
statusEl.textContent = 'Datos fiscales guardados';
statusEl.style.color = 'var(--color-success)';
}
setTimeout(() => { if (statusEl) { statusEl.textContent = ''; statusEl.style.color = ''; } }, 4000);
} catch (e) {
if (statusEl) {
statusEl.textContent = e.message;
statusEl.style.color = 'var(--color-error)';
}
}
}
async function setupFacturapi(btn) { async function setupFacturapi(btn) {
if (!btn) return; if (!btn) return;
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Configurando...'; btn.textContent = 'Configurando...';
try { try {
const res = await api('/facturapi/setup', { method: 'POST' }); const res = await api('/facturapi/setup', { method: 'POST' });
if (res.error) { alert('Organización vinculada: ' + res.org_id);
alert('Aviso: ' + res.error);
} else if (res.configured) {
alert('Organización vinculada y configurada: ' + res.org_id);
} else {
alert('Organización vinculada: ' + res.org_id + '. Revisa pasos pendientes.');
}
loadFacturapiStatus(); loadFacturapiStatus();
} catch (e) { } catch (e) {
alert('Error: ' + e.message); alert('Error: ' + e.message);
} finally {
btn.disabled = false; btn.disabled = false;
btn.textContent = 'Crear / Vincular Organización'; btn.textContent = 'Crear / Vincular Organización';
} }
@@ -508,13 +335,6 @@ const Invoicing = (() => {
document.getElementById('csd-key-label').textContent = 'Subir llave privada .key'; document.getElementById('csd-key-label').textContent = 'Subir llave privada .key';
} }
function reloadManifiesto() {
const iframe = document.getElementById('manifiesto-iframe');
if (iframe) {
iframe.src = iframe.src;
}
}
function updateFileLabels() { function updateFileLabels() {
const cer = document.getElementById('csd-cer'); const cer = document.getElementById('csd-cer');
const key = document.getElementById('csd-key'); const key = document.getElementById('csd-key');
@@ -854,6 +674,13 @@ const Invoicing = (() => {
window.submitNewInvoice = submitNewInvoice; window.submitNewInvoice = submitNewInvoice;
window.notaCreditoPlaceholder = notaCreditoPlaceholder; window.notaCreditoPlaceholder = notaCreditoPlaceholder;
return {
switchTab, loadFacturas, loadNotas, loadComplementos, loadCancelaciones, loadFacturapiStatus,
showDetail, showCancelModal, confirmCancel, processQueue,
showNewInvoiceModal, closeNewInvoiceModal, submitNewInvoice, notaCreditoPlaceholder,
openGlobalInvoiceModal, previewGlobalInvoice, generateGlobalInvoice, setupFacturapi,
uploadCsd, resetCsdForm,
};
// Register Cmd+K items // Register Cmd+K items
if (typeof registerCmdKItem === "function") { if (typeof registerCmdKItem === "function") {
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" }); registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
@@ -862,15 +689,4 @@ const Invoicing = (() => {
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" }); registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
} }
return {
switchTab, loadFacturas, loadNotas, loadComplementos, loadCancelaciones, loadFacturapiStatus,
loadEmisorData, saveEmisorData,
showDetail, showCancelModal, confirmCancel, processQueue,
showNewInvoiceModal, closeNewInvoiceModal, submitNewInvoice, notaCreditoPlaceholder,
openGlobalInvoiceModal, previewGlobalInvoice, generateGlobalInvoice, setupFacturapi,
uploadCsd, resetCsdForm, reloadManifiesto,
filterFacturas, exportFacturasCSV, exportNotasCSV,
newCreditNote, newPaymentComplement,
};
})(); })();

View File

@@ -82,13 +82,7 @@
localStorage.setItem('pos_token', result.data.token); localStorage.setItem('pos_token', result.data.token);
localStorage.setItem('pos_employee', JSON.stringify(result.data.employee)); localStorage.setItem('pos_employee', JSON.stringify(result.data.employee));
localStorage.setItem('pos_tenant_id', tenantId); localStorage.setItem('pos_tenant_id', tenantId);
document.cookie = 'pos_role=' + (result.data.employee.role || '') + '; path=/pos; SameSite=Lax';
var role = (result.data.employee.role || '').toLowerCase();
if (role === 'workshop' || role === 'mechanic' || role === 'counter') {
window.location.href = '/pos/workshop';
} else {
window.location.href = '/pos/catalog'; window.location.href = '/pos/catalog';
}
}) })
.catch(function() { .catch(function() {
errorEl.textContent = 'Error de conexion'; errorEl.textContent = 'Error de conexion';

View File

@@ -1 +1 @@
!function(){"use strict";var t="",e=document.querySelectorAll("#pinDots .pin-dot"),n=document.getElementById("loginError"),o=new URLSearchParams(window.location.search).get("tenant")||localStorage.getItem("pos_tenant_id"),a=localStorage.getItem("pos_device_id");function i(){e.forEach((function(e,n){e.classList.toggle("filled",n<t.length)}))}a||(a="dev-"+Date.now()+"-"+Math.random().toString(36).substr(2,9),localStorage.setItem("pos_device_id",a)),window.addDigit=function(e){t.length>=4||(t+=e,i(),n.textContent="",4===t.length&&submitPin())},window.clearPin=function(){t="",i(),n.textContent=""},window.submitPin=function(){4===t.length&&(n.textContent="",fetch("/pos/api/auth/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({tenant_id:parseInt(o),pin:t,device_id:a})}).then((function(t){return t.json().then((function(e){return{ok:t.ok,data:e}}))})).then((function(t){if(!t.ok)return n.textContent=t.data.error||"Error de autenticacion",void clearPin();localStorage.setItem("pos_token",t.data.token),localStorage.setItem("pos_employee",JSON.stringify(t.data.employee)),localStorage.setItem("pos_tenant_id",o),document.cookie="pos_role="+(t.data.employee.role||"")+"; path=/pos; SameSite=Lax",window.location.href="/pos/catalog"})).catch((function(){n.textContent="Error de conexion",clearPin()})))},document.addEventListener("keydown",(function(t){t.key>="0"&&t.key<="9"?addDigit(t.key):"Backspace"===t.key?clearPin():"Enter"===t.key&&submitPin()}));var r=localStorage.getItem("pos_token");r&&o&&(!function(t){try{var e=t.split(".");if(3!==e.length)return!1;var n=e[1].replace(/-/g,"+").replace(/_/g,"/"),o=JSON.parse(atob(n));return!!o.exp&&1e3*o.exp>Date.now()+3e4}catch(t){return!1}}(r)?(localStorage.removeItem("pos_token"),localStorage.removeItem("pos_employee")):window.location.href="/pos/catalog")}(); !function(){"use strict";var t="",e=document.querySelectorAll("#pinDots .pin-dot"),n=document.getElementById("loginError"),o=new URLSearchParams(window.location.search).get("tenant")||localStorage.getItem("pos_tenant_id"),a=localStorage.getItem("pos_device_id");function i(){e.forEach((function(e,n){e.classList.toggle("filled",n<t.length)}))}a||(a="dev-"+Date.now()+"-"+Math.random().toString(36).substr(2,9),localStorage.setItem("pos_device_id",a)),window.addDigit=function(e){t.length>=4||(t+=e,i(),n.textContent="",4===t.length&&submitPin())},window.clearPin=function(){t="",i(),n.textContent=""},window.submitPin=function(){4===t.length&&(n.textContent="",fetch("/pos/api/auth/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({tenant_id:parseInt(o),pin:t,device_id:a})}).then((function(t){return t.json().then((function(e){return{ok:t.ok,data:e}}))})).then((function(t){if(!t.ok)return n.textContent=t.data.error||"Error de autenticacion",void clearPin();localStorage.setItem("pos_token",t.data.token),localStorage.setItem("pos_employee",JSON.stringify(t.data.employee)),localStorage.setItem("pos_tenant_id",o),window.location.href="/pos/catalog"})).catch((function(){n.textContent="Error de conexion",clearPin()})))},document.addEventListener("keydown",(function(t){t.key>="0"&&t.key<="9"?addDigit(t.key):"Backspace"===t.key?clearPin():"Enter"===t.key&&submitPin()}));var r=localStorage.getItem("pos_token");r&&o&&(!function(t){try{var e=t.split(".");if(3!==e.length)return!1;var n=e[1].replace(/-/g,"+").replace(/_/g,"/"),o=JSON.parse(atob(n));return!!o.exp&&1e3*o.exp>Date.now()+3e4}catch(t){return!1}}(r)?(localStorage.removeItem("pos_token"),localStorage.removeItem("pos_employee")):window.location.href="/pos/catalog")}();

View File

@@ -64,7 +64,7 @@
} }
} }
window.startOAuth = async function() { window.startOAuth = function() {
var clientId = document.getElementById('cfgClientId').value.trim(); var clientId = document.getElementById('cfgClientId').value.trim();
var clientSecret = document.getElementById('cfgClientSecret').value.trim(); var clientSecret = document.getElementById('cfgClientSecret').value.trim();
var category = document.getElementById('cfgCategory').value.trim(); var category = document.getElementById('cfgCategory').value.trim();
@@ -75,34 +75,15 @@
return; return;
} }
// Purge any previously stored secret from older versions // Save config locally for the callback
localStorage.removeItem('meli_client_secret'); localStorage.setItem('meli_client_id', clientId);
localStorage.removeItem('meli_client_id'); localStorage.setItem('meli_client_secret', clientSecret);
localStorage.setItem('meli_category', category); localStorage.setItem('meli_category', category);
localStorage.setItem('meli_shipping', shipping); localStorage.setItem('meli_shipping', shipping);
try { var redirectUri = window.location.origin + '/pos/marketplace-external/callback';
var res = await fetch(API + '/connect/init', { var authUrl = 'https://auth.mercadolibre.com.mx/authorization?response_type=code&client_id=' + encodeURIComponent(clientId) + '&redirect_uri=' + encodeURIComponent(redirectUri) + '&scope=read+write+offline_access';
method: 'POST', window.location.href = authUrl;
headers: headers(),
body: JSON.stringify({
client_id: clientId,
client_secret: clientSecret,
category: category,
shipping: shipping
})
});
var data = await res.json();
if (!res.ok) {
alert('Error iniciando conexión: ' + (data.error || 'Unknown'));
return;
}
if (data.auth_url) {
window.location.href = data.auth_url;
}
} catch (e) {
alert('Error: ' + e.message);
}
}; };
window.disconnectMeli = async function() { window.disconnectMeli = async function() {
@@ -382,6 +363,8 @@
var authCode = urlParams.get('code'); var authCode = urlParams.get('code');
if (authCode && window.location.pathname.includes('marketplace-external')) { if (authCode && window.location.pathname.includes('marketplace-external')) {
(async function() { (async function() {
var clientId = localStorage.getItem('meli_client_id');
var clientSecret = localStorage.getItem('meli_client_secret');
var redirectUri = window.location.origin + '/pos/marketplace-external/callback'; var redirectUri = window.location.origin + '/pos/marketplace-external/callback';
try { try {
var res = await fetch(API + '/connect', { var res = await fetch(API + '/connect', {
@@ -389,6 +372,8 @@
headers: headers(), headers: headers(),
body: JSON.stringify({ body: JSON.stringify({
code: authCode, code: authCode,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri, redirect_uri: redirectUri,
}) })
}); });

View File

@@ -160,15 +160,7 @@
delete el.dataset.originalContent; delete el.dataset.originalContent;
}; };
// ── Loading / Empty state helpers ──────────────────────────────────────── // ── Empty state helper ────────────────────────────────────────
window.renderLoadingState = function(opts) {
opts = opts || {};
var message = opts.message || 'Cargando...';
return '<div class="empty-state empty-state--loading" role="status" aria-live="polite">' +
'<div class="empty-state__icon"><svg class="empty-state__spinner" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg></div>' +
'<div class="empty-state__title">' + message + '</div>' +
'</div>';
};
window.renderEmptyState = function(opts) { window.renderEmptyState = function(opts) {
opts = opts || {}; opts = opts || {};
var icon = opts.icon || '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="9" y1="9" x2="15" y2="15"/><line x1="15" y1="9" x2="9" y2="15"/></svg>'; var icon = opts.icon || '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="9" y1="9" x2="15" y2="15"/><line x1="15" y1="9" x2="9" y2="15"/></svg>';

View File

@@ -21,24 +21,10 @@ const POS = (() => {
let paymentMethod = 'efectivo'; let paymentMethod = 'efectivo';
let canViewCost = false; let canViewCost = false;
let employeeMaxDiscount = 100; let employeeMaxDiscount = 100;
let lastSaleId = sessionStorage.getItem('pos_last_sale_id') || null; let lastSaleId = null;
let lastSaleData = null; let lastSaleData = null;
let searchTimeout = null; let searchTimeout = null;
let customerSearchTimeout = null; let customerSearchTimeout = null;
let canCancel = false;
let canDiscount = false;
let canEditPrice = false;
let canCreateWorkshopOrder = false;
let canCreateLayaway = false;
let canCreateRemission = false;
let counterRemissionEnabled = false;
let allowZeroPriceSales = true;
let allowNegativeStock = false;
let workshopModuleEnabled = true;
let currentPerms = [];
let receiptConfig = {};
let couriers = [];
let selectedCourierId = null;
// Currency-aware formatter: reads pos_currency from localStorage // Currency-aware formatter: reads pos_currency from localStorage
const _posCurrency = localStorage.getItem('pos_currency') || 'MXN'; const _posCurrency = localStorage.getItem('pos_currency') || 'MXN';
@@ -46,16 +32,6 @@ const POS = (() => {
const _currLocale = _posCurrency === 'USD' ? 'en-US' : 'es-MX'; const _currLocale = _posCurrency === 'USD' ? 'en-US' : 'es-MX';
const fmt = (n) => (_currSymbols[_posCurrency] || '$') + parseFloat(n || 0).toLocaleString(_currLocale, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const fmt = (n) => (_currSymbols[_posCurrency] || '$') + parseFloat(n || 0).toLocaleString(_currLocale, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
// Price based on customer tier: base price (price_1) with tier discount.
// Tier 2 = Taller (5% off), Tier 3 = Mayoreo (10% off), Tier 1 = base.
function priceForTier(basePrice, tier) {
const p = parseFloat(basePrice) || 0;
const t = parseInt(tier, 10) || 1;
if (t === 2) return Math.round(p * 0.95 * 100) / 100;
if (t === 3) return Math.round(p * 0.90 * 100) / 100;
return p;
}
function headers() { function headers() {
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }; return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
} }
@@ -78,48 +54,6 @@ const POS = (() => {
setTimeout(() => el.remove(), 2100); setTimeout(() => el.remove(), 2100);
} }
// ─── Permission-based UI toggles ─────
function applyPermissionsUI() {
function hide(selector) {
const el = document.querySelector(selector);
if (el) el.style.display = 'none';
}
function show(selector) {
const el = document.querySelector(selector);
if (el) el.style.display = '';
}
if (!canCancel) {
hide('#btnCancelSale');
hide('#fkeyEsc');
}
if (!canDiscount) hide('[onclick="POS.applyDiscount()"]');
if (!canEditPrice) hide('[onclick="POS.modifyPrice()"]');
if (!canCreateWorkshopOrder || !workshopModuleEnabled) {
hide('[onclick="POS.createServiceOrder()"]');
hide('[title="Orden de servicio"]');
}
if (!canCreateLayaway) hide('[onclick="POS.createLayaway()"]');
// Counter remission workflow
if (canCreateRemission) {
hide('#btnCobrar');
hide('.fkey[onclick="POS.checkout()"]');
hide('[onclick="POS.createLayaway()"]');
hide('[onclick="POS.saveQuotation()"]');
show('#btnRemission');
show('#courierSelectField');
} else {
hide('#btnRemission');
hide('#courierSelectField');
}
if (!currentPerms.includes('pos.sell')) {
hide('#btnPayRemission');
} else {
show('#btnPayRemission');
}
}
// ─── Init ──────────────────────────── // ─── Init ────────────────────────────
async function init() { async function init() {
// Parse JWT to get employee info // Parse JWT to get employee info
@@ -127,44 +61,9 @@ const POS = (() => {
const payload = JSON.parse(atob(token.split('.')[1])); const payload = JSON.parse(atob(token.split('.')[1]));
document.getElementById('employeeName').textContent = payload.name || 'Empleado'; document.getElementById('employeeName').textContent = payload.name || 'Empleado';
document.getElementById('branchName').textContent = payload.branch_name || ''; document.getElementById('branchName').textContent = payload.branch_name || '';
const perms = payload.permissions || []; canViewCost = (payload.permissions || []).includes('pos.view_cost');
currentPerms = perms;
const employeeRole = payload.role || '';
canViewCost = perms.includes('pos.view_cost');
canCancel = perms.includes('pos.cancel');
canDiscount = perms.includes('pos.discount');
canEditPrice = perms.includes('config.edit_prices') || employeeRole === 'cashier' || employeeRole === 'counter';
canCreateWorkshopOrder = perms.includes('workshop.edit');
canCreateLayaway = perms.includes('pos.sell');
employeeMaxDiscount = payload.max_discount_pct || 100; employeeMaxDiscount = payload.max_discount_pct || 100;
// Counter remission feature
try {
const crCfg = await api('/pos/api/config/counter-remission');
counterRemissionEnabled = crCfg.enabled === true;
} catch (e) {
counterRemissionEnabled = false;
}
// Sales settings (zero-price and negative-stock toggles)
try {
const ssCfg = await api('/pos/api/config/sales-settings');
allowZeroPriceSales = ssCfg.allow_zero_price_sales !== false;
allowNegativeStock = ssCfg.allow_negative_stock === true;
} catch (e) {
allowZeroPriceSales = true;
allowNegativeStock = false;
}
// Module toggles (from app-init preloaded modules)
try {
var modules = window.POS_USER && window.POS_USER.modules;
if (!modules) modules = JSON.parse(localStorage.getItem('pos_modules') || '{}');
workshopModuleEnabled = modules.workshop !== false;
} catch (e) {
workshopModuleEnabled = true;
}
// Counter remission workflow applies only to the counter role.
canCreateRemission = counterRemissionEnabled && employeeRole === 'counter' && perms.includes('pos.remission');
// Show cost/margin columns and toggle button if permission // Show cost/margin columns and toggle button if permission
if (canViewCost) { if (canViewCost) {
document.getElementById('thCost').style.display = ''; document.getElementById('thCost').style.display = '';
@@ -179,8 +78,6 @@ const POS = (() => {
const parts = payload.name.split(' '); const parts = payload.name.split(' ');
avatar.textContent = parts.map(p => p[0]).join('').substring(0, 2).toUpperCase(); avatar.textContent = parts.map(p => p[0]).join('').substring(0, 2).toUpperCase();
} }
applyPermissionsUI();
} catch (e) { } catch (e) {
console.warn('Could not parse token:', e); console.warn('Could not parse token:', e);
} }
@@ -205,10 +102,8 @@ const POS = (() => {
showToast(`Modo conversion: Cotizacion #${convertQuoteId}. El pago convertira la cotizacion en venta.`); showToast(`Modo conversion: Cotizacion #${convertQuoteId}. El pago convertira la cotizacion en venta.`);
} }
// Load current register and receipt config // Load current register
await loadRegister(); await loadRegister();
await loadReceiptConfig();
await loadCouriers();
// Setup event listeners // Setup event listeners
setupKeyboard(); setupKeyboard();
@@ -228,48 +123,14 @@ const POS = (() => {
currentRegister = null; currentRegister = null;
document.getElementById('registerInfo').innerHTML = document.getElementById('registerInfo').innerHTML =
'<span style="color:var(--color-error);cursor:pointer;" onclick="POS.showOpenRegisterModal()" title="Clic para abrir caja">&#x26A0; Sin caja abierta — Clic para abrir</span>'; '<span style="color:var(--color-error);cursor:pointer;" onclick="POS.showOpenRegisterModal()" title="Clic para abrir caja">&#x26A0; Sin caja abierta — Clic para abrir</span>';
// Force open register modal on first load only for users who can sell // Force open register modal on first load
if (currentPerms.includes('pos.sell')) {
showOpenRegisterModal(); showOpenRegisterModal();
} }
}
} catch (e) { } catch (e) {
console.warn('Register check failed:', e); console.warn('Register check failed:', e);
} }
} }
async function loadReceiptConfig() {
try {
receiptConfig = await api('/pos/api/config/receipt');
} catch (e) {
console.warn('Could not load receipt config:', e);
receiptConfig = {};
}
}
async function loadCouriers() {
try {
const data = await api('/pos/api/logistics/couriers');
couriers = data.couriers || [];
const sel = document.getElementById('remissionCourier');
if (sel) {
sel.innerHTML = '<option value="">-- Sin repartidor --</option>' +
couriers.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
sel.addEventListener('change', () => {
selectedCourierId = sel.value ? parseInt(sel.value, 10) : null;
});
}
} catch (e) {
console.warn('Could not load couriers:', e);
couriers = [];
}
}
function rc(key, fallback) {
const v = receiptConfig[key];
return v !== undefined && v !== null && v !== '' ? v : fallback;
}
function showOpenRegisterModal() { function showOpenRegisterModal() {
document.getElementById('openRegisterModal').classList.add('open'); document.getElementById('openRegisterModal').classList.add('open');
document.getElementById('registerOpenResult').innerHTML = ''; document.getElementById('registerOpenResult').innerHTML = '';
@@ -417,7 +278,6 @@ const POS = (() => {
} }
function openCancelModal() { function openCancelModal() {
if (!canCancel) { showToast('No tienes permiso para cancelar'); return; }
const overlay = document.getElementById('overlay-cancelar-venta'); const overlay = document.getElementById('overlay-cancelar-venta');
const dialog = document.getElementById('modal-cancelar-venta'); const dialog = document.getElementById('modal-cancelar-venta');
if (overlay) overlay.classList.add('active'); if (overlay) overlay.classList.add('active');
@@ -447,7 +307,6 @@ const POS = (() => {
} }
function applyDiscount() { function applyDiscount() {
if (!canDiscount) { showToast('No tienes permiso para aplicar descuentos'); return; }
if (selectedRow < 0 || selectedRow >= cart.length) { if (selectedRow < 0 || selectedRow >= cart.length) {
showToast('Selecciona un articulo primero', 'warn'); showToast('Selecciona un articulo primero', 'warn');
return; return;
@@ -462,20 +321,7 @@ const POS = (() => {
} }
} }
function isZeroPriceItem(item) {
const unitPrice = parseFloat(item.unit_price || 0);
const qty = parseFloat(item.quantity || 1);
const discount = parseFloat(item.discount_pct || 0);
return (unitPrice * qty * (1 - discount / 100)) <= 0;
}
function findZeroPriceItem() {
if (allowZeroPriceSales) return null;
return cart.find(isZeroPriceItem) || null;
}
function modifyPrice() { function modifyPrice() {
if (!canEditPrice) { showToast('No tienes permiso para modificar precios'); return; }
if (selectedRow < 0 || selectedRow >= cart.length) { if (selectedRow < 0 || selectedRow >= cart.length) {
showToast('Selecciona un articulo primero', 'warn'); showToast('Selecciona un articulo primero', 'warn');
return; return;
@@ -483,15 +329,12 @@ const POS = (() => {
const p = prompt('Nuevo precio unitario:', cart[selectedRow].unit_price); const p = prompt('Nuevo precio unitario:', cart[selectedRow].unit_price);
if (p !== null) { if (p !== null) {
const n = parseFloat(p); const n = parseFloat(p);
if (n < 0) { showToast('Precio no válido'); return; } if (n >= 0) {
if (!allowZeroPriceSales && n === 0) {
showToast('No está permitido dejar el precio en $0');
return;
}
cart[selectedRow].unit_price = n; cart[selectedRow].unit_price = n;
renderCart(); renderCart();
} }
} }
}
// Wire confirm-cancel button // Wire confirm-cancel button
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
@@ -676,11 +519,26 @@ const POS = (() => {
if (data.data.length === 0) { if (data.data.length === 0) {
container.innerHTML = '<div style="padding:20px;text-align:center;color:var(--color-text-muted);">Sin resultados</div>'; container.innerHTML = '<div style="padding:20px;text-align:center;color:var(--color-text-muted);">Sin resultados</div>';
if (window.BarcodeFeedback) BarcodeFeedback.error(); if (window.BarcodeFeedback) BarcodeFeedback.error();
} else if (data.data.length === 1 && q.length >= 8) {
// Auto-select single result on barcode scan (long codes)
const item = data.data[0];
let price = item.price_1;
if (currentCustomer) {
const tier = currentCustomer.price_tier || 1;
price = tier === 3 ? item.price_3 : tier === 2 ? item.price_2 : item.price_1;
}
addFromSearch(item, price);
input.value = '';
hideSearchResults();
return;
} else { } else {
let html = ''; let html = '';
data.data.forEach(item => { data.data.forEach(item => {
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : 1; let price = item.price_1;
const price = priceForTier(item.price_1, tier); if (currentCustomer) {
const tier = currentCustomer.price_tier || 1;
price = tier === 3 ? item.price_3 : tier === 2 ? item.price_2 : item.price_1;
}
html += `<div style="padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--color-border);cursor:pointer;display:flex;justify-content:space-between;align-items:center;transition:var(--transition-fast);" onmouseover="this.style.background='var(--color-primary-muted)'" onmouseout="this.style.background=''" onclick='POS.addFromSearch(${JSON.stringify(item).replace(/'/g, "&#39;")}, ${price})'> html += `<div style="padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--color-border);cursor:pointer;display:flex;justify-content:space-between;align-items:center;transition:var(--transition-fast);" onmouseover="this.style.background='var(--color-primary-muted)'" onmouseout="this.style.background=''" onclick='POS.addFromSearch(${JSON.stringify(item).replace(/'/g, "&#39;")}, ${price})'>
<div> <div>
<div style="font-weight:var(--font-weight-semibold);">${item.name}</div> <div style="font-weight:var(--font-weight-semibold);">${item.name}</div>
@@ -775,7 +633,7 @@ const POS = (() => {
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : 1; const tier = currentCustomer ? (currentCustomer.price_tier || 1) : 1;
cart.forEach(item => { cart.forEach(item => {
if (item.price_1 > 0) { if (item.price_1 > 0) {
item.unit_price = priceForTier(item.price_1, tier); item.unit_price = tier === 3 ? item.price_3 : tier === 2 ? item.price_2 : item.price_1;
} }
}); });
} }
@@ -971,12 +829,9 @@ const POS = (() => {
transferencia: 'refPayment', transferencia: 'refPayment',
tarjeta: 'refPayment', tarjeta: 'refPayment',
mixto: 'mixedPayment', mixto: 'mixedPayment',
credito: 'creditPayment',
cheque: 'chequePayment',
pendiente: 'pendingPayment',
}; };
['cashPayment', 'refPayment', 'mixedPayment', 'creditPayment', 'chequePayment', 'pendingPayment'].forEach(id => { ['cashPayment', 'refPayment', 'mixedPayment'].forEach(id => {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) { if (el) {
const isActive = el.id === tabs[method]; const isActive = el.id === tabs[method];
@@ -990,12 +845,6 @@ const POS = (() => {
const ref = document.getElementById('paymentRef'); const ref = document.getElementById('paymentRef');
if (ref) ref.focus(); if (ref) ref.focus();
} }
if (method === 'cheque') {
const chequeAmount = document.getElementById('chequeAmount');
if (chequeAmount) chequeAmount.value = fmt(getTotal());
const chequeRef = document.getElementById('chequeRef');
if (chequeRef) chequeRef.focus();
}
} }
function updateChange() { function updateChange() {
@@ -1033,7 +882,6 @@ const POS = (() => {
let amountPaid = 0; let amountPaid = 0;
let paymentDetails = []; let paymentDetails = [];
let reference = ''; let reference = '';
let saleType = 'cash';
if (paymentMethod === 'efectivo') { if (paymentMethod === 'efectivo') {
amountPaid = parseFloat(document.getElementById('cashReceived').value) || 0; amountPaid = parseFloat(document.getElementById('cashReceived').value) || 0;
@@ -1053,20 +901,6 @@ const POS = (() => {
} }
}); });
if (amountPaid < total) { alert(`Monto total insuficiente. Falta: ${fmt(total - amountPaid)}`); return; } if (amountPaid < total) { alert(`Monto total insuficiente. Falta: ${fmt(total - amountPaid)}`); return; }
} else if (paymentMethod === 'credito') {
if (!currentCustomer) { alert('Seleccione un cliente para venta a crédito'); return; }
const available = (currentCustomer.credit_limit || 0) - (currentCustomer.credit_balance || 0);
if (total > available) {
alert(`Crédito insuficiente. Disponible: ${fmt(available)}, Total: ${fmt(total)}`);
return;
}
saleType = 'credit';
amountPaid = 0;
} else if (paymentMethod === 'cheque') {
amountPaid = total;
reference = document.getElementById('chequeRef').value.trim();
} else if (paymentMethod === 'pendiente') {
amountPaid = 0;
} }
const saleData = { const saleData = {
@@ -1079,7 +913,7 @@ const POS = (() => {
})), })),
customer_id: currentCustomer ? currentCustomer.id : null, customer_id: currentCustomer ? currentCustomer.id : null,
payment_method: paymentMethod, payment_method: paymentMethod,
sale_type: saleType, sale_type: 'cash',
register_id: currentRegister ? currentRegister.id : null, register_id: currentRegister ? currentRegister.id : null,
amount_paid: amountPaid, amount_paid: amountPaid,
payment_details: paymentDetails, payment_details: paymentDetails,
@@ -1087,12 +921,6 @@ const POS = (() => {
generate_cfdi: document.getElementById('cfdiCheck').checked, generate_cfdi: document.getElementById('cfdiCheck').checked,
}; };
const zeroItem = findZeroPriceItem();
if (zeroItem) {
showToast('No está permitido vender artículos en $0: ' + (zeroItem.name || zeroItem.part_number));
return;
}
const confirmBtn = document.getElementById('btnConfirmPayment'); const confirmBtn = document.getElementById('btnConfirmPayment');
confirmBtn.disabled = true; confirmBtn.disabled = true;
confirmBtn.textContent = 'Procesando...'; confirmBtn.textContent = 'Procesando...';
@@ -1104,10 +932,9 @@ const POS = (() => {
const convertData = { const convertData = {
register_id: currentRegister ? currentRegister.id : null, register_id: currentRegister ? currentRegister.id : null,
payment_method: paymentMethod, payment_method: paymentMethod,
sale_type: saleType, sale_type: 'cash',
amount_paid: amountPaid, amount_paid: amountPaid,
payment_details: paymentDetails, payment_details: paymentDetails,
reference: reference,
}; };
sale = await api('/pos/api/quotations/' + convertQuoteId + '/convert', { sale = await api('/pos/api/quotations/' + convertQuoteId + '/convert', {
method: 'POST', method: 'POST',
@@ -1125,7 +952,6 @@ const POS = (() => {
lastSaleId = sale.id; lastSaleId = sale.id;
lastSaleData = sale; lastSaleData = sale;
try { sessionStorage.setItem('pos_last_sale_id', sale.id); } catch(e) {}
closePaymentModal(); closePaymentModal();
showTicket(sale); showTicket(sale);
@@ -1142,18 +968,22 @@ const POS = (() => {
} }
} }
// ─── Counter Remission Note ──────────── // ─── Credit Sale ─────────────────────
async function createRemissionNote() { async function creditSale() {
if (cart.length === 0) { showToast('Carrito vacio'); return; } if (cart.length === 0) { alert('Carrito vacio'); return; }
if (!canCreateRemission) { showToast('No tienes permiso para generar notas de remision'); return; } if (!currentCustomer) { alert('Seleccione un cliente para venta a credito'); return; }
const zeroItem = findZeroPriceItem(); if (!currentRegister) { alert('No hay caja abierta.'); return; }
if (zeroItem) {
showToast('No está permitido vender artículos en $0: ' + (zeroItem.name || zeroItem.part_number));
return;
}
const total = getTotal(); const total = getTotal();
const noteData = { const available = (currentCustomer.credit_limit || 0) - (currentCustomer.credit_balance || 0);
if (currentCustomer.credit_limit > 0 && total > available) {
if (!confirm(`Credito insuficiente. Disponible: ${fmt(available)}, Total: ${fmt(total)}. Continuar?`)) {
return;
}
}
const saleData = {
items: cart.map(item => ({ items: cart.map(item => ({
inventory_id: item.inventory_id, inventory_id: item.inventory_id,
quantity: item.quantity, quantity: item.quantity,
@@ -1161,158 +991,34 @@ const POS = (() => {
discount_pct: item.discount_pct, discount_pct: item.discount_pct,
tax_rate: item.tax_rate, tax_rate: item.tax_rate,
})), })),
customer_id: currentCustomer ? currentCustomer.id : null, customer_id: currentCustomer.id,
notes: 'Nota de remision generada desde mostrador', payment_method: 'credito',
sale_type: 'credit',
register_id: currentRegister ? currentRegister.id : null, register_id: currentRegister ? currentRegister.id : null,
courier_id: selectedCourierId, amount_paid: 0,
}; };
try { try {
const sale = await api('/pos/api/sales/remission', { const sale = await api('/pos/api/sales', {
method: 'POST', method: 'POST',
body: JSON.stringify(noteData), body: JSON.stringify(saleData),
}); });
if (selectedCourierId) {
const courier = couriers.find(c => c.id === selectedCourierId);
sale.courier_name = courier ? courier.name : '';
}
lastSaleId = sale.id; lastSaleId = sale.id;
lastSaleData = sale; lastSaleData = sale;
try { sessionStorage.setItem('pos_last_sale_id', sale.id); } catch(e) {}
showTicket(sale); showTicket(sale);
cart = []; cart = [];
selectedRow = -1; selectedRow = -1;
clearCustomer(); clearCustomer();
selectedCourierId = null;
const sel = document.getElementById('remissionCourier');
if (sel) sel.value = '';
renderCart(); renderCart();
showToast(`Nota de remision NR-${sale.id} generada`);
} catch (e) { } catch (e) {
alert('Error al generar nota de remision: ' + e.message); alert('Error: ' + e.message);
} }
} }
async function openPayRemissionModal() {
if (!currentPerms.includes('pos.sell')) { showToast('No tienes permiso para cobrar notas'); return; }
document.getElementById('payRemissionFolio').value = '';
document.getElementById('payRemissionDetail').innerHTML = '';
document.getElementById('payRemissionResult').innerHTML = '';
document.getElementById('payRemissionActions').style.display = 'none';
document.getElementById('payRemissionModal').classList.add('open');
setTimeout(() => document.getElementById('payRemissionFolio').focus(), 100);
}
function closePayRemissionModal() {
document.getElementById('payRemissionModal').classList.remove('open');
}
let pendingRemissionToPay = null;
async function searchRemissionToPay() {
const folio = parseInt(document.getElementById('payRemissionFolio').value, 10);
if (!folio) { showToast('Ingresa un folio valido'); return; }
pendingRemissionToPay = null;
try {
const sale = await api('/pos/api/sales/' + folio);
if (sale.status !== 'pending_payment') {
document.getElementById('payRemissionDetail').innerHTML = `<div class="error-msg">La venta ${folio} no esta pendiente de pago</div>`;
document.getElementById('payRemissionActions').style.display = 'none';
return;
}
pendingRemissionToPay = sale;
let itemsHtml = (sale.items || []).map(it => `
<div class="ticket-line">
<span class="qty">${it.quantity}</span>
<span class="name">${it.name || ''}</span>
<span class="subtotal">${fmt(it.subtotal || 0)}</span>
</div>
`).join('');
document.getElementById('payRemissionDetail').innerHTML = `
<div class="info-row"><span>Cliente:</span><span>${sale.customer_name || 'Publico General'}</span></div>
<div class="info-row"><span>Vendedor:</span><span>${sale.employee_name || ''}</span></div>
<div class="info-row"><span>Total:</span><span class="grand">${fmt(sale.total)}</span></div>
<hr class="divider">
${itemsHtml}
`;
document.getElementById('payRemissionActions').style.display = '';
} catch (e) {
document.getElementById('payRemissionDetail').innerHTML = `<div class="error-msg">${e.message}</div>`;
document.getElementById('payRemissionActions').style.display = 'none';
}
}
async function confirmPayRemission() {
if (!pendingRemissionToPay) return;
const sale = pendingRemissionToPay;
const paymentMethod = document.getElementById('payRemissionMethod').value;
let amountPaid = parseFloat(sale.total);
let paymentDetails = [];
let reference = '';
if (paymentMethod === 'efectivo') {
const received = parseFloat(document.getElementById('payRemissionReceived').value) || 0;
if (received < sale.total) { alert('Monto insuficiente'); return; }
amountPaid = received;
} else if (paymentMethod === 'mixto') {
const rows = document.querySelectorAll('#payRemissionMixed .mixed-row');
let sum = 0;
rows.forEach(row => {
const method = row.querySelector('select').value;
const amount = parseFloat(row.querySelector('.mixed-amount').value) || 0;
const ref = row.querySelectorAll('input')[1]?.value || '';
if (amount > 0) {
paymentDetails.push({ method, amount, reference: ref });
sum += amount;
}
});
if (sum < sale.total) { alert(`Monto total insuficiente. Falta: ${fmt(sale.total - sum)}`); return; }
amountPaid = sum;
} else {
reference = document.getElementById('payRemissionReference').value.trim();
}
try {
const result = await api('/pos/api/sales/' + sale.id + '/pay', {
method: 'POST',
body: JSON.stringify({
payment_method: paymentMethod,
amount_paid: amountPaid,
payment_details: paymentDetails,
register_id: currentRegister ? currentRegister.id : null,
reference: reference,
}),
});
closePayRemissionModal();
showToast(`Nota NR-${sale.id} pagada`);
// Refresh sale object to print paid ticket
const updated = await api('/pos/api/sales/' + sale.id);
lastSaleId = updated.id;
lastSaleData = updated;
showTicket(updated);
} catch (e) {
alert('Error al cobrar nota: ' + e.message);
}
}
function updatePayRemissionMethod() {
const method = document.getElementById('payRemissionMethod').value;
const cashEl = document.getElementById('payRemissionCash');
const refEl = document.getElementById('payRemissionRef');
const mixedEl = document.getElementById('payRemissionMixed');
if (cashEl) cashEl.style.display = method === 'efectivo' ? '' : 'none';
if (refEl) refEl.style.display = (method === 'transferencia' || method === 'tarjeta') ? '' : 'none';
if (mixedEl) mixedEl.style.display = method === 'mixto' ? '' : 'none';
}
// ─── Quotation ─────────────────────── // ─── Quotation ───────────────────────
async function saveQuotation() { async function saveQuotation() {
if (cart.length === 0) { showToast('Carrito vacio'); return; } if (cart.length === 0) { showToast('Carrito vacio'); return; }
const zeroItem = findZeroPriceItem();
if (zeroItem) {
showToast('No está permitido cotizar artículos en $0: ' + (zeroItem.name || zeroItem.part_number));
return;
}
const body = { const body = {
items: cart.map(item => ({ items: cart.map(item => ({
@@ -1351,13 +1057,7 @@ const POS = (() => {
// ─── Layaway ───────────────────────── // ─── Layaway ─────────────────────────
async function createLayaway() { async function createLayaway() {
if (!canCreateLayaway) { showToast('No tienes permiso para crear apartados'); return; }
if (cart.length === 0) { alert('Carrito vacio'); return; } if (cart.length === 0) { alert('Carrito vacio'); return; }
const zeroItem = findZeroPriceItem();
if (zeroItem) {
alert('No está permitido apartar artículos en $0: ' + (zeroItem.name || zeroItem.part_number));
return;
}
if (!currentCustomer) { alert('Seleccione un cliente para apartado'); return; } if (!currentCustomer) { alert('Seleccione un cliente para apartado'); return; }
const total = getTotal(); const total = getTotal();
@@ -1397,138 +1097,6 @@ const POS = (() => {
} }
} }
// ─── Service Order from POS ──────────
function createServiceOrder() {
if (!workshopModuleEnabled) { showToast('El módulo de taller no está habilitado'); return; }
if (!canCreateWorkshopOrder) { showToast('No tienes permiso para ordenes de taller'); return; }
if (cart.length === 0) { showToast('Carrito vacio'); return; }
const zeroItem = findZeroPriceItem();
if (zeroItem) {
showToast('No está permitido generar orden con artículos en $0: ' + (zeroItem.name || zeroItem.part_number));
return;
}
const custInput = document.getElementById('soCustomer');
if (custInput) custInput.value = currentCustomer ? currentCustomer.name : 'Publico General';
document.getElementById('serviceOrderModal').classList.add('open');
}
function closeServiceOrderModal() {
document.getElementById('serviceOrderModal').classList.remove('open');
}
async function confirmServiceOrder() {
const customerId = currentCustomer ? currentCustomer.id : null;
if (!customerId) { alert('Seleccione un cliente para la orden de servicio'); return; }
const vehicleText = document.getElementById('soVehicle').value.trim();
const delivery = document.getElementById('soDelivery').value;
const notes = document.getElementById('soNotes').value.trim();
const isDirect = document.getElementById('soDirect').checked;
const items = cart.map(item => ({
inventory_id: item.inventory_id,
part_number: item.part_number,
name: item.name,
quantity: item.quantity,
unit_price: item.unit_price,
status: 'pending'
}));
try {
const order = await api('/pos/api/service-orders/from-pos', {
method: 'POST',
body: JSON.stringify({
customer_id: customerId,
priority: 'normal',
reception_notes: (vehicleText ? 'Vehiculo: ' + vehicleText + '. ' : '') + (notes || ''),
delivery_method: delivery || null,
is_direct: isDirect,
items: items
})
});
showToast(`Orden de servicio ${order.order_number} creada`);
closeServiceOrderModal();
cart = [];
selectedRow = -1;
renderCart();
showServiceOrderTicket(order);
} catch (e) {
alert('Error: ' + e.message);
}
}
function showServiceOrderTicket(order) {
const dateStr = new Date(order.created_at).toLocaleString('es-MX', {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit'
});
const customerName = order.customer_name || (currentCustomer ? currentCustomer.name : 'Publico General');
let itemsHtml = '';
(order.items || []).forEach(item => {
const itemTotal = item.unit_price * item.quantity;
itemsHtml += `
<div class="item-line-wide ticket-line">
<span class="qty">${item.quantity}</span>
<span class="name">${item.name || ''}</span>
<span class="price">${fmt(item.unit_price)}</span>
<span class="subtotal">${fmt(itemTotal)}</span>
</div>`;
});
const ticketHtml = `
<div class="store-name">NEXUS AUTOPARTS</div>
<div class="store-tagline">Orden de servicio</div>
<div class="store-info">
Sucursal: ${currentRegister ? currentRegister.branch_name || '' : ''}<br>
RFC: NAU210315XX1
</div>
<hr class="divider-double">
<div class="folio-line">
<span>ORDEN: ${order.order_number}</span>
<span>${dateStr}</span>
</div>
<div class="ticket-row" style="font-size: 9px; color: #555; margin-bottom: 4px;">
<span>Cliente: ${customerName}</span>
</div>
<hr class="divider">
<div class="item-line-wide" style="font-weight: bold; font-size: 9px; color: #555; text-transform: uppercase;">
<span class="qty">Cant</span>
<span class="name">Descripcion</span>
<span class="price">P. Unit</span>
<span class="subtotal">Importe</span>
</div>
<hr class="divider" style="margin: 2px 0;">
${itemsHtml}
<hr class="divider-double">
<div class="total-section">
<div class="total-line">
<span>Refacciones:</span><span>${fmt(order.total_parts || 0)}</span>
</div>
<div class="total-line">
<span>Mano de obra:</span><span>${fmt(order.total_labor || 0)}</span>
</div>
<div class="total-line grand">
<span>TOTAL:</span><span>${fmt(order.total || 0)}</span>
</div>
</div>
<hr class="divider">
<div class="footer-section">
<div class="thanks">Gracias por su preferencia!</div>
<div>Conserve su ticket como comprobante.</div>
</div>
`;
const printArea = document.getElementById('ticketContent');
if (printArea) printArea.innerHTML = ticketHtml;
const preview = document.getElementById('ticketPreviewContent');
if (preview) preview.innerHTML = ticketHtml;
const modalHeader = document.querySelector('#ticketModal .modal-header h3');
if (modalHeader) modalHeader.textContent = 'Ticket de Orden de Servicio';
document.getElementById('ticketModal').classList.add('open');
}
// ─── Ticket ────────────────────────── // ─── Ticket ──────────────────────────
function showTicket(sale) { function showTicket(sale) {
const dateStr = new Date(sale.created_at).toLocaleString('es-MX', { const dateStr = new Date(sale.created_at).toLocaleString('es-MX', {
@@ -1536,15 +1104,14 @@ const POS = (() => {
hour: '2-digit', minute: '2-digit' hour: '2-digit', minute: '2-digit'
}); });
const isRemission = sale.status === 'pending_payment'; const customerName = currentCustomer ? currentCustomer.name : 'Publico General';
const customerName = sale.customer_name || (currentCustomer ? currentCustomer.name : 'Publico General'); const customerRfc = currentCustomer && currentCustomer.rfc ? currentCustomer.rfc : '';
const customerRfc = sale.customer_rfc || (currentCustomer && currentCustomer.rfc ? currentCustomer.rfc : '');
let itemsHtml = ''; let itemsHtml = '';
(sale.items || []).forEach(item => { (sale.items || []).forEach(item => {
const itemTotal = (item.unit_price * item.quantity * (1 - (item.discount_pct || 0) / 100)); const itemTotal = (item.unit_price * item.quantity * (1 - (item.discount_pct || 0) / 100));
itemsHtml += ` itemsHtml += `
<div class="item-line-wide ticket-line"> <div class="item-line-wide">
<span class="qty">${item.quantity}</span> <span class="qty">${item.quantity}</span>
<span class="name">${item.name || ''}</span> <span class="name">${item.name || ''}</span>
<span class="price">${fmt(item.unit_price)}</span> <span class="price">${fmt(item.unit_price)}</span>
@@ -1552,48 +1119,22 @@ const POS = (() => {
</div>`; </div>`;
}); });
const cfg = receiptConfig || {};
const showLogo = cfg.show_logo && cfg.logo;
const showRfc = cfg.show_rfc !== false;
const showAddress = cfg.show_address;
const showPhone = cfg.show_phone;
const showIva = cfg.show_iva_breakdown !== false;
const showPayment = cfg.show_payment_details !== false;
const showEmployee = cfg.show_employee;
const storeName = rc('store_name', 'NEXUS AUTOPARTS');
const storeTagline = rc('tagline', 'Tu conexion con las refacciones');
const storeRfc = rc('rfc', 'NAU210315XX1');
const storeAddress = rc('address', '');
const storePhone = rc('phone', '');
const thanksMsg = rc('thanks_message', 'Gracias por su compra!');
const footerMsg = rc('footer', 'Conserve su ticket como comprobante.');
let storeInfoLines = [];
if (currentRegister && currentRegister.branch_name) storeInfoLines.push(`Sucursal: ${currentRegister.branch_name}`);
if (showEmployee && sale.employee_name) storeInfoLines.push(`Atendió: ${sale.employee_name}`);
if (showRfc && storeRfc) storeInfoLines.push(`RFC: ${storeRfc}`);
if (showAddress && storeAddress) storeInfoLines.push(storeAddress);
if (showPhone && storePhone) storeInfoLines.push(`Tel: ${storePhone}`);
const logoHtml = showLogo ? `<div class="ticket-logo-wrap"><img src="${cfg.logo}" alt="Logo" class="ticket-logo" style="max-width:140px;max-height:70px;object-fit:contain;"></div>` : '';
const ticketHtml = ` const ticketHtml = `
${logoHtml} <div class="store-name">NEXUS AUTOPARTS</div>
<div class="store-name">${storeName}</div> <div class="store-tagline">Tu conexion con las refacciones</div>
${storeTagline ? `<div class="store-tagline">${storeTagline}</div>` : ''}
<div class="store-info"> <div class="store-info">
${storeInfoLines.join('<br>')} Sucursal: ${currentRegister ? currentRegister.branch_name || '' : ''}<br>
RFC: NAU210315XX1
</div> </div>
<hr class="divider-double"> <hr class="divider-double">
<div class="folio-line"> <div class="folio-line">
<span>${isRemission ? 'NOTA DE REMISION' : 'VENTA'}: ${isRemission ? 'NR' : 'V'}-${sale.id}</span> <span>VENTA: V-${sale.id}</span>
<span>${dateStr}</span> <span>${dateStr}</span>
</div> </div>
<div class="ticket-row" style="font-size: 9px; color: #555; margin-bottom: 4px;"> <div class="ticket-row" style="font-size: 9px; color: #555; margin-bottom: 4px;">
<span>Cliente: ${customerName}</span> <span>Cliente: ${customerName}</span>
${customerRfc ? `<span>RFC: ${customerRfc}</span>` : ''} ${customerRfc ? `<span>RFC: ${customerRfc}</span>` : ''}
</div> </div>
${isRemission && sale.courier_name ? `<div class="ticket-row" style="font-size: 9px; color: #555; margin-bottom: 4px;"><span>Repartidor: ${sale.courier_name}</span></div>` : ''}
<hr class="divider"> <hr class="divider">
<div class="item-line-wide" style="font-weight: bold; font-size: 9px; color: #555; text-transform: uppercase;"> <div class="item-line-wide" style="font-weight: bold; font-size: 9px; color: #555; text-transform: uppercase;">
<span class="qty">Cant</span> <span class="qty">Cant</span>
@@ -1609,20 +1150,15 @@ const POS = (() => {
<span>Subtotal:</span><span>${fmt(sale.subtotal)}</span> <span>Subtotal:</span><span>${fmt(sale.subtotal)}</span>
</div> </div>
${sale.discount_total > 0 ? `<div class="total-line"><span>Descuento:</span><span>-${fmt(sale.discount_total)}</span></div>` : ''} ${sale.discount_total > 0 ? `<div class="total-line"><span>Descuento:</span><span>-${fmt(sale.discount_total)}</span></div>` : ''}
${showIva ? `<div class="total-line"><span>IVA:</span><span>${fmt(sale.tax_total)}</span></div>` : ''} <div class="total-line">
<span>IVA 16%:</span><span>${fmt(sale.tax_total)}</span>
</div>
<div class="total-line grand"> <div class="total-line grand">
<span>TOTAL:</span><span>${fmt(sale.total)}</span> <span>TOTAL:</span><span>${fmt(sale.total)}</span>
</div> </div>
</div> </div>
${showPayment ? `
<hr class="divider"> <hr class="divider">
<div class="payment-section"> <div class="payment-section">
${isRemission ? `
<div class="ticket-row" style="font-weight: bold; color: #b91c1c;">
<span>Estado:</span><span>PENDIENTE DE PAGO</span>
</div>
<div style="font-size: 9px; text-align: center; margin-top: 4px;">Presente esta nota en caja para pagar</div>
` : `
<div class="ticket-row"> <div class="ticket-row">
<span>Forma de pago:</span><span>${sale.payment_method || paymentMethod}</span> <span>Forma de pago:</span><span>${sale.payment_method || paymentMethod}</span>
</div> </div>
@@ -1633,13 +1169,11 @@ const POS = (() => {
<div class="ticket-row" style="font-weight: bold;"> <div class="ticket-row" style="font-weight: bold;">
<span>Cambio:</span><span>${fmt(sale.change_given || 0)}</span> <span>Cambio:</span><span>${fmt(sale.change_given || 0)}</span>
</div>` : ''} </div>` : ''}
`} </div>
</div>` : ''}
<hr class="divider"> <hr class="divider">
<div class="footer-section"> <div class="footer-section">
<div class="thanks">${isRemission ? 'Gracias por su preferencia' : thanksMsg}</div> <div class="thanks">Gracias por su compra!</div>
${footerMsg && !isRemission ? `<div>${footerMsg}</div>` : ''} <div>Conserve su ticket como comprobante.</div>
${isRemission ? '<div style="font-size: 9px;">Conserve esta nota para el pago</div>' : ''}
</div> </div>
`; `;
@@ -1648,8 +1182,6 @@ const POS = (() => {
if (printArea) printArea.innerHTML = ticketHtml; if (printArea) printArea.innerHTML = ticketHtml;
const preview = document.getElementById('ticketPreviewContent'); const preview = document.getElementById('ticketPreviewContent');
if (preview) preview.innerHTML = ticketHtml; if (preview) preview.innerHTML = ticketHtml;
const modalHeader = document.querySelector('#ticketModal .modal-header h3');
if (modalHeader) modalHeader.textContent = isRemission ? 'Nota de Remision' : 'Ticket de Venta';
document.getElementById('ticketModal').classList.add('open'); document.getElementById('ticketModal').classList.add('open');
} }
@@ -1659,84 +1191,11 @@ const POS = (() => {
} }
function printTicket() { function printTicket() {
const src = document.getElementById('ticketContent'); // Make print area visible for @media print
if (!src) { console.warn('printTicket: ticketContent not found'); return; } const area = document.getElementById('ticketPrintArea');
if (area) area.style.display = 'block';
try {
// Paper width: 58 mm -> 48 mm printable; 80 mm -> 72 mm printable.
const paperWidth = parseInt(rc('paper_width', '80'), 10) || 80;
const is58 = paperWidth === 58;
const printWidth = is58 ? 48 : 72;
const fontSize = is58 ? '12pt' : '14pt';
const smallFont = is58 ? '10pt' : '12pt';
const nameMax = is58 ? '20mm' : '38mm';
let iframe = document.getElementById('ticketPrintFrame');
if (!iframe) {
iframe = document.createElement('iframe');
iframe.id = 'ticketPrintFrame';
iframe.style.position = 'fixed';
iframe.style.left = '-9999px';
iframe.style.top = '0';
iframe.style.width = printWidth + 'mm';
iframe.style.border = '0';
document.body.appendChild(iframe);
}
const doc = iframe.contentDocument || iframe.contentWindow.document;
doc.open();
doc.write(`<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
@page { margin: 0; size: ${printWidth}mm auto; }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: auto; overflow: visible; background: #fff; color: #000; font-family: 'Courier New', Courier, 'Lucida Console', monospace; font-size: ${fontSize}; line-height: 1.55; -webkit-font-smoothing: none; -moz-osx-font-smoothing: unset; text-rendering: geometricPrecision; }
body { width: ${printWidth}mm; }
.ticket { width: ${printWidth}mm; padding: 3mm 2.5mm 4mm; border: none !important; box-shadow: none !important; }
.item-line-wide { display: grid !important; grid-template-columns: ${is58 ? '9mm 1fr auto' : '10mm 1fr auto auto'} !important; gap: 2mm; font-size: ${fontSize}; margin-bottom: 2.5mm; padding-left: 1mm; align-items: start; }
.item-line-wide .qty { text-align: center; }
.item-line-wide .name { white-space: normal !important; word-break: break-word; overflow-wrap: anywhere; max-width: ${nameMax}; padding-left: 1mm; }
.item-line-wide .price { text-align: right; white-space: nowrap; ${is58 ? 'display: none !important;' : ''} }
.item-line-wide .subtotal { text-align: right; white-space: nowrap; }
.ticket-row, .folio-line, .total-line { display: flex !important; justify-content: space-between; gap: 2mm; }
.total-line.grand { font-size: ${is58 ? '13pt' : '15pt'}; font-weight: bold; border-top: 0.6mm solid #000; padding-top: 1.5mm; margin-top: 1.5mm; }
.store-name { font-size: ${is58 ? '14pt' : '16pt'}; font-weight: bold; text-align: center; margin-bottom: 3px; }
.store-tagline, .store-info { font-size: ${smallFont}; text-align: center; color: #000; margin-bottom: 5px; line-height: 1.45; }
.divider { border: none; border-top: 1px dashed #999; margin: 5px 0; }
.divider-double { border: none; border-top: 2px solid #333; margin: 5px 0; }
.footer-section { text-align: center; margin-top: 8px; font-size: ${smallFont}; color: #000; line-height: 1.45; }
.payment-section, .total-section { width: 100%; }
img { max-width: 100%; height: auto; }
</style>
</head>
<body>${src.innerHTML}</body>
</html>`);
doc.close();
// Measure real content height and set exact page size (avoids phantom 3276 mm).
const bodyEl = doc.body;
const heightPx = Math.max(bodyEl.scrollHeight, bodyEl.offsetHeight);
const heightMm = Math.ceil((heightPx * 25.4 / 96) + 5);
const exactPage = doc.createElement('style');
exactPage.textContent = `@page { margin: 0; size: ${printWidth}mm ${heightMm}mm; }`;
doc.head.appendChild(exactPage);
iframe.contentWindow.focus();
setTimeout(() => {
try {
iframe.contentWindow.print();
} catch (e) {
console.error('printTicket: iframe.print failed', e);
window.print(); window.print();
} setTimeout(() => { if (area) area.style.display = 'none'; }, 500);
}, 200);
} catch (e) {
console.error('printTicket error:', e);
alert('No se pudo preparar la impresion del ticket. Revise la consola.');
}
} }
// ─── Thermal Printing ───────────────── // ─── Thermal Printing ─────────────────
@@ -1758,8 +1217,7 @@ const POS = (() => {
return; return;
} }
if (!lastSaleId) { showToast('No hay venta para imprimir'); return; } if (!lastSaleId) { showToast('No hay venta para imprimir'); return; }
const paperWidth = parseInt(rc('paper_width', '80'), 10) || 80; const ok = await NexusPrinter.printSale(lastSaleId);
const ok = await NexusPrinter.printSale(lastSaleId, paperWidth);
if (ok) { if (ok) {
showToast('Ticket enviado a impresora termica'); showToast('Ticket enviado a impresora termica');
} else { } else {
@@ -1828,10 +1286,6 @@ const POS = (() => {
e.preventDefault(); e.preventDefault();
openDrawer(); openDrawer();
break; break;
case 'F7':
e.preventDefault();
createServiceOrder();
break;
case 'Escape': case 'Escape':
e.preventDefault(); e.preventDefault();
if (document.getElementById('paymentModal').classList.contains('open')) { if (document.getElementById('paymentModal').classList.contains('open')) {
@@ -1840,8 +1294,6 @@ const POS = (() => {
closeNewCustomerModal(); closeNewCustomerModal();
} else if (document.getElementById('ticketModal').classList.contains('open')) { } else if (document.getElementById('ticketModal').classList.contains('open')) {
closeTicketModal(); closeTicketModal();
} else if (document.getElementById('serviceOrderModal').classList.contains('open')) {
closeServiceOrderModal();
} else if (document.querySelector('.confirm-overlay.active')) { } else if (document.querySelector('.confirm-overlay.active')) {
// Close cancel modal // Close cancel modal
const overlay = document.getElementById('overlay-cancelar-venta'); const overlay = document.getElementById('overlay-cancelar-venta');
@@ -1909,14 +1361,6 @@ const POS = (() => {
}); });
} }
// Register Cmd+K items
if (typeof registerCmdKItem === "function") {
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
registerCmdKItem({ group: "Principal", label: "Catálogo", href: "/pos/catalog", icon: "📁" });
registerCmdKItem({ group: "Principal", label: "Clientes", href: "/pos/customers", icon: "👤" });
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
}
// ─── Public API ────────────────────── // ─── Public API ──────────────────────
init(); init();
@@ -1928,10 +1372,7 @@ const POS = (() => {
showNewCustomerModal, closeNewCustomerModal, saveNewCustomer, showNewCustomerModal, closeNewCustomerModal, saveNewCustomer,
checkout, confirmPayment, closePaymentModal, checkout, confirmPayment, closePaymentModal,
selectPaymentMethod, updateChange, updateMixedTotal, selectPaymentMethod, updateChange, updateMixedTotal,
createRemissionNote, openPayRemissionModal, closePayRemissionModal, creditSale, saveQuotation, createLayaway,
searchRemissionToPay, confirmPayRemission, updatePayRemissionMethod,
saveQuotation, createLayaway,
createServiceOrder, closeServiceOrderModal, confirmServiceOrder, showServiceOrderTicket,
showLastSale, openDrawer, showLastSale, openDrawer,
showTicket, closeTicketModal, printTicket, showTicket, closeTicketModal, printTicket,
connectThermal, thermalPrint, connectThermal, thermalPrint,
@@ -1939,5 +1380,12 @@ const POS = (() => {
showCutZModal, closeCutZModal, loadCutX, confirmCutZ, showCutZModal, closeCutZModal, loadCutX, confirmCutZ,
openCancelModal, closeCancelModal, changeQuantity, applyDiscount, modifyPrice, openCancelModal, closeCancelModal, changeQuantity, applyDiscount, modifyPrice,
}; };
// Register Cmd+K items
if (typeof registerCmdKItem === "function") {
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
registerCmdKItem({ group: "Principal", label: "Catálogo", href: "/pos/catalog", icon: "📁" });
registerCmdKItem({ group: "Principal", label: "Clientes", href: "/pos/customers", icon: "👤" });
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
}
})(); })();

View File

@@ -54,25 +54,12 @@ const Reports = (() => {
} }
// Track which tabs have been loaded // Track which tabs have been loaded
var loaded = { ventas: false, inventario: false, clientes: false, financieros: false, historico: false, cortes: false }; var loaded = { ventas: false, inventario: false, clientes: false, financieros: false, historico: false };
function currentUser() {
return (typeof window.POS_USER !== 'undefined') ? window.POS_USER : {};
}
function hasPerm(p) {
var u = currentUser();
return (u.role === 'owner' || u.role === 'admin') || (u.permissions || []).indexOf(p) !== -1;
}
function isLimitedUser() {
var u = currentUser();
var r = (u.role || '').toLowerCase();
return (r === 'cashier' || r === 'counter') && !hasPerm('pos.view');
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Theme switcher // Theme switcher
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
function setTheme(theme) { /* function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-theme', theme);
try { localStorage.setItem('pos_theme', theme); } catch(e) {} try { localStorage.setItem('pos_theme', theme); } catch(e) {}
var btnInd = document.getElementById('btn-industrial'); var btnInd = document.getElementById('btn-industrial');
@@ -80,7 +67,15 @@ const Reports = (() => {
if (btnInd) btnInd.classList.toggle('is-active', theme === 'industrial'); if (btnInd) btnInd.classList.toggle('is-active', theme === 'industrial');
if (btnMod) btnMod.classList.toggle('is-active', theme === 'modern'); if (btnMod) btnMod.classList.toggle('is-active', theme === 'modern');
} }
window.setTheme = setTheme; window.setTheme = setTheme;*/
function setTheme(theme) {
if (window.posSetTheme) window.posSetTheme(theme);
var btnInd = document.getElementById('btn-industrial');
var btnMod = document.getElementById('btn-modern');
if (btnInd) btnInd.classList.toggle('is-active', theme === 'industrial');
if (btnMod) btnMod.classList.toggle('is-active', theme === 'modern');
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Tab switcher with lazy loading // Tab switcher with lazy loading
@@ -99,7 +94,6 @@ const Reports = (() => {
else if (id === 'clientes') loadClientes(); else if (id === 'clientes') loadClientes();
else if (id === 'financieros') loadFinancieros(); else if (id === 'financieros') loadFinancieros();
else if (id === 'historico') loadHistorico(); else if (id === 'historico') loadHistorico();
else if (id === 'cortes') loadCortes();
} }
} }
window.switchTab = switchTab; window.switchTab = switchTab;
@@ -518,7 +512,7 @@ const Reports = (() => {
agingEl.innerHTML = spinner(); agingEl.innerHTML = spinner();
try { try {
var data = await apiFetch('/pos/api/accounting/aging-summary'); var data = await apiFetch('/pos/api/accounting/aging');
var clients = data.data || []; var clients = data.data || [];
var totals = data.totals || {}; var totals = data.totals || {};
@@ -751,153 +745,6 @@ const Reports = (() => {
} }
} }
// =========================================================================
// TAB 6: CORTES DE CAJA
// =========================================================================
async function loadCortes() {
loaded.cortes = true;
var dateFrom = document.getElementById('cortes-date-from').value;
var dateTo = document.getElementById('cortes-date-to').value;
var employeeId = document.getElementById('cortes-employee').value;
// Cashiers/counters without pos.view can only see their own cuts
var u = currentUser();
if (isLimitedUser()) {
employeeId = u.employeeId || '';
var empSel = document.getElementById('cortes-employee');
if (empSel) empSel.value = employeeId;
}
var kpiEl = document.getElementById('cortes-kpis');
var detalleEl = document.getElementById('cortes-detalle');
var ventasDetalleEl = document.getElementById('corte-ventas-detalle');
kpiEl.innerHTML = spinner();
detalleEl.innerHTML = spinner();
if (ventasDetalleEl) ventasDetalleEl.style.display = 'none';
var params = new URLSearchParams();
if (dateFrom) params.set('date_from', dateFrom);
if (dateTo) params.set('date_to', dateTo);
if (employeeId) params.set('employee_id', employeeId);
params.set('per_page', '200');
try {
var data = await apiFetch('/pos/api/register/history?' + params.toString());
var regs = data.data || [];
var totalEsperado = 0;
var totalCierre = 0;
var totalDiferencia = 0;
regs.forEach(function(r) {
totalEsperado += r.expected_amount || 0;
totalCierre += r.closing_amount || 0;
totalDiferencia += r.difference || 0;
});
kpiEl.innerHTML =
kpiCard('Cortes', fmtInt(regs.length), 'en el periodo') +
kpiCard('Ventas esperadas', '$' + fmt(totalEsperado), 'total acumulado') +
kpiCard('Cierre real', '$' + fmt(totalCierre), 'efectivo contado') +
kpiCard('Diferencia', '$' + fmt(totalDiferencia), totalDiferencia >= 0 ? 'sobrante' : 'faltante');
var cHtml = '<div class="table-card__header"><span class="table-card__title">Cortes de Caja</span>' +
'<span class="pill pill--muted">' + (data.pagination ? data.pagination.total : regs.length) + ' registros</span></div>';
cHtml += '<div class="table-wrap"><table class="data-table"><thead><tr>' +
'<th>Caja</th><th>Empleado</th><th>Apertura</th><th>Cierre</th>' +
'<th class="align-right">Monto Apertura</th><th class="align-right">Esperado</th>' +
'<th class="align-right">Cierre Real</th><th class="align-right">Diferencia</th>' +
'<th>Acciones</th>' +
'</tr></thead><tbody>';
regs.forEach(function(r) {
var diffColor = r.difference < 0 ? 'color:var(--color-error)' :
r.difference > 0 ? 'color:var(--color-warning)' : 'color:var(--color-success)';
cHtml += '<tr data-register-id="' + r.id + '" style="cursor:pointer" onclick="Reports.showCorteVentas(' + r.id + ')">' +
'<td class="td-mono">#' + r.register_number + '</td>' +
'<td class="td-strong">' + (r.employee_name || '--') + '</td>' +
'<td style="color:var(--color-text-muted)">' + fmtDateTime(r.opened_at) + '</td>' +
'<td style="color:var(--color-text-muted)">' + fmtDateTime(r.closed_at) + '</td>' +
'<td class="align-right td-mono">$' + fmt(r.opening_amount) + '</td>' +
'<td class="align-right td-mono">$' + fmt(r.expected_amount) + '</td>' +
'<td class="align-right td-mono">$' + fmt(r.closing_amount) + '</td>' +
'<td class="align-right td-mono" style="' + diffColor + '">$' + fmt(r.difference) + '</td>' +
'<td><button class="btn btn-sm btn-ghost" onclick="event.stopPropagation(); Reports.showCorteVentas(' + r.id + ')">Ver ventas</button></td></tr>';
});
cHtml += '</tbody></table></div>';
detalleEl.innerHTML = regs.length ? cHtml : emptyMsg('No hay cortes de caja en el periodo seleccionado');
} catch (err) {
kpiEl.innerHTML = errorMsg('Error cargando cortes de caja: ' + err.message);
detalleEl.innerHTML = '';
}
}
// -------------------------------------------------------------------------
// Detail: sales for a selected cash cut
// -------------------------------------------------------------------------
async function showCorteVentas(registerId) {
var panel = document.getElementById('corte-ventas-detalle');
if (!panel) return;
panel.style.display = 'block';
panel.innerHTML = spinner();
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
var methodLabels = {
'cash': 'Efectivo', 'card': 'Tarjeta', 'transfer': 'Transferencia',
'credit': 'Crédito', 'mixed': 'Mixto', 'efectivo': 'Efectivo',
'tarjeta': 'Tarjeta', 'transferencia': 'Transferencia'
};
try {
var data = await apiFetch('/pos/api/register/' + registerId + '/sales');
var sales = data.sales || [];
var summary = data.summary || {};
var total = summary.total || 0;
var count = summary.count || 0;
var byMethod = summary.by_method || {};
var hHtml = '<div class="table-card__header"><span class="table-card__title">Ventas del corte #' + registerId + '</span>' +
'<span class="pill pill--muted">' + count + ' ventas · $' + fmt(total) + '</span></div>';
// Summary by payment method
var methods = Object.entries(byMethod).sort(function(a, b) { return b[1] - a[1]; });
if (methods.length) {
hHtml += '<div style="display:flex;gap:var(--space-3);flex-wrap:wrap;padding:var(--space-4) var(--space-5);border-bottom:1px solid var(--color-border);">';
methods.forEach(function(m) {
var label = methodLabels[m[0]] || m[0];
hHtml += '<div class="pill pill--muted">' + label + ': <strong>$' + fmt(m[1]) + '</strong></div>';
});
hHtml += '</div>';
}
hHtml += '<div class="table-wrap"><table class="data-table"><thead><tr>' +
'<th># Venta</th><th>Fecha</th><th>Cliente</th><th>Método</th>' +
'<th class="align-right">Subtotal</th><th class="align-right">Desc.</th>' +
'<th class="align-right">Total</th><th>Estado</th>' +
'</tr></thead><tbody>';
sales.forEach(function(s) {
var statusPill = s.status === 'completed' ? 'pill--success' :
s.status === 'cancelled' ? 'pill--error' : 'pill--warning';
var statusLabel = s.status === 'completed' ? 'Completada' :
s.status === 'cancelled' ? 'Cancelada' : s.status;
var method = methodLabels[s.payment_method] || s.payment_method || '--';
hHtml += '<tr><td class="td-mono">' + s.id + '</td>' +
'<td>' + fmtDateTime(s.created_at) + '</td>' +
'<td>' + (s.customer_name || 'Mostrador') + '</td>' +
'<td>' + method + '</td>' +
'<td class="align-right td-mono">$' + fmt(s.subtotal) + '</td>' +
'<td class="align-right td-mono">$' + fmt(s.discount_total) + '</td>' +
'<td class="align-right td-mono-accent">$' + fmt(s.total) + '</td>' +
'<td><span class="pill ' + statusPill + '">' + statusLabel + '</span></td></tr>';
});
hHtml += '</tbody></table></div>';
panel.innerHTML = sales.length ? hHtml : emptyMsg('No hay ventas registradas en este corte');
} catch (err) {
panel.innerHTML = errorMsg('Error cargando ventas del corte: ' + err.message);
}
}
window.showCorteVentas = showCorteVentas;
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Init // Init
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -914,9 +761,6 @@ const Reports = (() => {
updateClock(); updateClock();
setInterval(updateClock, 1000); setInterval(updateClock, 1000);
var u = currentUser();
var limited = isLimitedUser();
// Set default date range: first day of current month to today // Set default date range: first day of current month to today
var now = new Date(); var now = new Date();
var firstDay = new Date(now.getFullYear(), now.getMonth(), 1); var firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
@@ -925,49 +769,6 @@ const Reports = (() => {
if (fromEl) fromEl.value = firstDay.toISOString().substring(0, 10); if (fromEl) fromEl.value = firstDay.toISOString().substring(0, 10);
if (toEl) toEl.value = now.toISOString().substring(0, 10); if (toEl) toEl.value = now.toISOString().substring(0, 10);
// Set default date range for cortes de caja
var cortesFrom = document.getElementById('cortes-date-from');
var cortesTo = document.getElementById('cortes-date-to');
if (cortesFrom) cortesFrom.value = firstDay.toISOString().substring(0, 10);
if (cortesTo) cortesTo.value = now.toISOString().substring(0, 10);
// Populate cajero filter for cortes de caja (admins/owners only see it)
var empSel = document.getElementById('cortes-employee');
if (empSel && !limited) {
apiFetch('/pos/api/config/employees?per_page=200').then(function(data) {
(data.data || []).forEach(function(e) {
var opt = document.createElement('option');
opt.value = e.id;
opt.textContent = e.name;
empSel.appendChild(opt);
});
}).catch(function() {});
} else if (empSel && limited) {
empSel.value = u.employeeId || '';
}
// For cashiers/counters without pos.view, limit the page to "Mis cortes"
if (limited) {
['ventas', 'inventario', 'clientes', 'financieros', 'historico'].forEach(function(id) {
var btn = document.querySelector('.tab-btn[onclick="switchTab(\'' + id + '\', this)"]');
if (btn) btn.style.display = 'none';
});
var empFilter = document.getElementById('cortes-employee-filter');
if (empFilter) empFilter.style.display = 'none';
document.querySelectorAll('.tab-panel').forEach(function(p) { p.classList.remove('is-active'); });
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('is-active'); });
var cortesPanel = document.getElementById('panel-cortes');
var cortesBtn = document.querySelector('.tab-btn[onclick="switchTab(\'cortes\', this)"]');
if (cortesPanel) cortesPanel.classList.add('is-active');
if (cortesBtn) {
cortesBtn.classList.add('is-active');
var svg = '<svg viewBox="0 0 15 15" fill="none" stroke="currentColor" stroke-width="1.4"><rect x="1" y="3" width="13" height="10" rx="1"/><path d="M4 7h7M4 10h5"/><circle cx="11" cy="10" r="1.5" fill="currentColor"/></svg>';
cortesBtn.innerHTML = svg + '<span>Mis cortes de caja</span>';
}
loadCortes();
}
// Populate financial period selectors // Populate financial period selectors
var monthSel = document.getElementById('fin-month'); var monthSel = document.getElementById('fin-month');
var yearSel = document.getElementById('fin-year'); var yearSel = document.getElementById('fin-year');
@@ -991,18 +792,15 @@ const Reports = (() => {
} }
} }
// Load the default active tab (ventas) only for privileged users // Load the default active tab (ventas)
if (!limited) {
loadVentas(); loadVentas();
} }
}
document.addEventListener('DOMContentLoaded', init); document.addEventListener('DOMContentLoaded', init);
return { return {
init, setTheme, switchTab, init, setTheme, switchTab,
loadVentas, loadInventario, loadClientes, loadFinancieros, loadHistorico, loadCortes, loadVentas, loadInventario, loadClientes, loadFinancieros, loadHistorico, fmt
showCorteVentas, fmt
}; };
// Register Cmd+K items // Register Cmd+K items
if (typeof registerCmdKItem === "function") { if (typeof registerCmdKItem === "function") {

View File

@@ -15,6 +15,7 @@ window.renderSidebar = function(modulesOverride) {
var initials = u.initials || '?'; var initials = u.initials || '?';
var currentPath = window.location.pathname; var currentPath = window.location.pathname;
var currentTheme = localStorage.getItem('pos_theme') || 'industrial'; var currentTheme = localStorage.getItem('pos_theme') || 'industrial';
var currentLang = localStorage.getItem('pos_lang') || 'es';
var modules = {}; var modules = {};
if (modulesOverride && typeof modulesOverride === 'object') { if (modulesOverride && typeof modulesOverride === 'object') {
@@ -30,73 +31,30 @@ window.renderSidebar = function(modulesOverride) {
return modules[key] !== false; return modules[key] !== false;
} }
var role = (u.role || '').toLowerCase();
var perms = u.permissions || [];
function hasPerm(p) {
return role === 'owner' || perms.indexOf(p) !== -1;
}
// Role-based section filtering. Owner/admin bypasses; other roles get
// only the sections allowed by their permissions.
function itemAllowed(id) {
if (role === 'owner' || role === 'admin') return true;
var base = [];
if (role === 'workshop' || role === 'mechanic') {
base = ['workshop'];
}
var permMap = {
'pos.sell': 'pos',
'pos.view': 'pos',
'catalog.view': 'catalog',
'inventory.view': 'inventory',
'customers.view': 'customers',
'workshop.view': 'workshop',
'pos.remission': 'remission_notes',
'invoicing.view': 'invoicing',
'quotations.view': 'quotations',
'accounting.view': 'accounting',
'reports.view': 'reports',
'dashboard.view': 'dashboard'
};
var allowed = base.slice();
for (var p in permMap) {
if (perms.indexOf(p) !== -1 && allowed.indexOf(permMap[p]) === -1) {
allowed.push(permMap[p]);
}
}
return allowed.indexOf(id) !== -1;
}
var navSections = [ var navSections = [
{ label: _t('nav_main'), items: [ { label: _t('nav_main'), items: [
{ id: 'dashboard', name: _t('dashboard'), href: '/pos/dashboard', icon: '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>' }, { name: _t('dashboard'), href: '/pos/dashboard', icon: '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>' },
{ id: 'pos', name: _t('pos'), href: '/pos/sale', icon: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>' }, { name: _t('pos'), href: '/pos/sale', icon: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>' },
moduleEnabled('catalog') ? { id: 'catalog', name: _t('catalog'), href: '/pos/catalog', icon: '<path d="M4 6h16M4 10h16M4 14h16M4 18h16"/>' } : null, moduleEnabled('catalog') ? { name: _t('catalog'), href: '/pos/catalog', icon: '<path d="M4 6h16M4 10h16M4 14h16M4 18h16"/>' } : null,
{ id: 'inventory', name: _t('inventory'), href: '/pos/inventory', icon: '<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>' }, { name: _t('inventory'), href: '/pos/inventory', icon: '<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>' },
].filter(Boolean).filter(function(i){ return itemAllowed(i.id); })}, ].filter(Boolean)},
{ label: _t('nav_management'), items: [ { label: _t('nav_management'), items: [
{ id: 'customers', name: _t('customers'), href: '/pos/customers', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>' }, { name: _t('customers'), href: '/pos/customers', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>' },
{ id: 'remission_notes', name: _t('remission_notes'), href: '/pos/remission-notes', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>' }, { name: 'Taller', href: '/pos/workshop', icon: '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>' },
moduleEnabled('workshop') ? { id: 'workshop', name: 'Taller', href: '/pos/workshop', icon: '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>' } : null, { name: 'Cotizaciones', href: '/pos/quotations', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="15" x2="15" y2="15"/><line x1="12" y1="12" x2="12" y2="18"/>' },
{ id: 'quotations', name: 'Cotizaciones', href: '/pos/quotations', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="15" x2="15" y2="15"/><line x1="12" y1="12" x2="12" y2="18"/>' }, moduleEnabled('marketplace') ? { name: 'Marketplace', href: '/pos/marketplace', icon: '<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>' } : null,
moduleEnabled('marketplace') ? { id: 'marketplace', name: 'Marketplace', href: '/pos/marketplace', icon: '<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>' } : null, moduleEnabled('meli') ? { name: 'MercadoLibre', href: '/pos/marketplace-external', icon: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>' } : null,
moduleEnabled('meli') ? { id: 'meli', name: 'MercadoLibre', href: '/pos/marketplace-external', icon: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>' } : null, { name: _t('invoicing'), href: '/pos/invoicing', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>' },
{ id: 'invoicing', name: _t('invoicing'), href: '/pos/invoicing', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>' }, { name: _t('accounting'), href: '/pos/accounting', icon: '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>' },
{ id: 'accounting', name: _t('accounting'), href: '/pos/accounting', icon: '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>' }, { name: _t('reports'), href: '/pos/reports', icon: '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>' },
{ id: 'reports', name: _t('reports'), href: '/pos/reports', icon: '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>' }, { name: _t('fleet'), href: '/pos/fleet', icon: '<path d="M1 13h22M1 13l2-6h6l2 6M9 7h6l2 6M15 13l2-6M5 17a2 2 0 1 0 0-4 2 2 0 0 0 0 4zM19 17a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"/>' },
moduleEnabled('whatsapp') ? { id: 'whatsapp', name: _t('whatsapp'), href: '/pos/whatsapp', icon: '<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>' } : null, moduleEnabled('whatsapp') ? { name: _t('whatsapp'), href: '/pos/whatsapp', icon: '<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>' } : null,
].filter(Boolean).filter(function(i){ return itemAllowed(i.id); })}, ].filter(Boolean)},
{ label: _t('nav_system'), items: [ { label: _t('nav_system'), items: [
{ id: 'config', name: _t('config'), href: '/pos/config', icon: '<circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>' }, { name: _t('config'), href: '/pos/config', icon: '<circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>' },
].filter(function(i){ return itemAllowed(i.id); })}, ]},
]; ];
// Remove sections whose items were all filtered out
navSections = navSections.filter(function(sec) { return sec.items.length > 0; });
function svgIcon(paths) { function svgIcon(paths) {
return '<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">' + paths + '</svg>'; return '<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">' + paths + '</svg>';
} }
@@ -124,6 +82,16 @@ window.renderSidebar = function(modulesOverride) {
+ '</button>' + '</button>'
+ '</div>'; + '</div>';
// Language toggle buttons
var langHtml = '<div class="sidebar__lang-toggle">'
+ '<button class="lang-toggle-btn' + (currentLang === 'es' ? ' is-active' : '') + '" onclick="setLang(\'es\')" title="Espanol">'
+ '<span class="lang-flag">MX</span> ES'
+ '</button>'
+ '<button class="lang-toggle-btn' + (currentLang === 'en' ? ' is-active' : '') + '" onclick="setLang(\'en\')" title="English">'
+ '<span class="lang-flag">US</span> EN'
+ '</button>'
+ '</div>';
window.updateThemeButtons = function() { window.updateThemeButtons = function() {
var t = localStorage.getItem('pos_theme') || 'industrial'; var t = localStorage.getItem('pos_theme') || 'industrial';
document.querySelectorAll('.theme-toggle-btn').forEach(function(b, i) { document.querySelectorAll('.theme-toggle-btn').forEach(function(b, i) {
@@ -141,6 +109,7 @@ window.renderSidebar = function(modulesOverride) {
+ '</div>' + '</div>'
+ '<nav class="sidebar__nav">' + navHtml + '</nav>' + '<nav class="sidebar__nav">' + navHtml + '</nav>'
+ themeHtml + themeHtml
+ langHtml
+ '<div class="sidebar__footer">' + '<div class="sidebar__footer">'
+ ' <div class="sidebar__user-avatar">' + initials + '</div>' + ' <div class="sidebar__user-avatar">' + initials + '</div>'
+ ' <div class="sidebar__user-info">' + ' <div class="sidebar__user-info">'
@@ -153,14 +122,14 @@ window.renderSidebar = function(modulesOverride) {
+ '</div>'; + '</div>';
// Replace existing sidebar // Replace existing sidebar
var existing = document.querySelector('.pos-sidebar, aside.sidebar, .sidebar, #sidebar'); var existing = document.querySelector('aside.sidebar, .sidebar, #sidebar');
if (existing) { if (existing) {
existing.className = 'pos-sidebar sidebar'; existing.className = 'pos-sidebar';
existing.innerHTML = sidebarHtml; existing.innerHTML = sidebarHtml;
existing.removeAttribute('style'); existing.removeAttribute('style');
} else { } else {
var el = document.createElement('aside'); var el = document.createElement('aside');
el.className = 'pos-sidebar sidebar'; el.className = 'pos-sidebar';
el.innerHTML = sidebarHtml; el.innerHTML = sidebarHtml;
document.body.insertBefore(el, document.body.firstChild); document.body.insertBefore(el, document.body.firstChild);
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,12 @@
// /home/Autopartes/pos/static/pwa/sw.js // /home/Autopartes/pos/static/pwa/sw.js
// Nexus POS — Service Worker // Nexus POS — Service Worker v17
// Self-contained vanilla JS. No external imports. // Self-contained vanilla JS. No external imports.
// //
// Bump VERSION whenever static assets change significantly. // Bump CACHE_NAME whenever static assets change significantly.
// The fetch handler normalizes static asset URLs (strips ?v= query strings) // The fetch handler normalizes static asset URLs (strips ?v= query strings)
// so templates can use cache-busting query params freely. // so templates can use cache-busting query params freely.
const VERSION = 54; const CACHE_NAME = 'nexus-pos-v20';
const CACHE_NAME = 'nexus-pos-v' + VERSION;
const APP_SHELL = [ const APP_SHELL = [
'/pos/static/css/tokens.css', '/pos/static/css/tokens.css',

View File

@@ -8,14 +8,15 @@
<link rel="stylesheet" href="/pos/static/css/chat.css" /> <link rel="stylesheet" href="/pos/static/css/chat.css" />
<link rel="stylesheet" href="/pos/static/css/tokens.css" /> <link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" /> <link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" /> <link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" /> <link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" /> <link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" /> <link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" /> <meta name="theme-color" content="#F5A623" />
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" /> <link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
<link rel="stylesheet" href="/pos/static/css/accounting.css?v=33"></head> <link rel="stylesheet" href="/pos/static/css/accounting.css">
</head>
<body> <body>
@@ -227,18 +228,19 @@
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> <svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" placeholder="Buscar cliente o factura..." /> <input type="text" placeholder="Buscar cliente o factura..." />
</div> </div>
<select id="cxc-status-filter" class="select-filter" onchange="Accounting.loadAging()"> <select class="select-filter">
<option value="all">Todos los estados</option> <option>Todos los estados</option>
<option value="pending">Vigente</option> <option>Vigente</option>
<option value="overdue">Vencida</option> <option>Vencida</option>
<option value="partial">Parcial</option> <option>Parcial</option>
<option value="ok">Pagada</option>
</select> </select>
<select class="select-filter" title="Filtro de sucursal (próximamente)"> <select class="select-filter">
<option>Todas las sucursales</option> <option>Todas las sucursales</option>
<option>Matriz</option>
<option>Sucursal Norte</option>
</select> </select>
<div class="toolbar__spacer"></div> <div class="toolbar__spacer"></div>
<button class="btn btn--ghost btn--sm" onclick="window.exportarCuentasPorCobrar()"> <button class="btn btn--ghost btn--sm">
<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> <svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Exportar Exportar
</button> </button>
@@ -279,15 +281,13 @@
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> <svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" placeholder="Buscar proveedor o factura..." /> <input type="text" placeholder="Buscar proveedor o factura..." />
</div> </div>
<select id="cxp-status-filter" class="select-filter" onchange="Accounting.loadAccountsPayable()"> <select class="select-filter">
<option value="all">Todos los estados</option> <option>Todos los estados</option>
<option value="pending">Vigente</option> <option>Vigente</option>
<option value="overdue">Vencida</option> <option>Vencida</option>
<option value="partial">Parcial</option>
<option value="ok">Pagada</option>
</select> </select>
<div class="toolbar__spacer"></div> <div class="toolbar__spacer"></div>
<button id="cxp-register-payment" class="btn btn--primary btn--sm" onclick="Accounting.registerPayablePayment()"> <button class="btn btn--primary btn--sm">
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> <svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Registrar Pago Registrar Pago
</button> </button>
@@ -424,11 +424,13 @@
=============================================================== --> =============================================================== -->
<div class="tab-panel" id="panel-cierre"> <div class="tab-panel" id="panel-cierre">
<div class="toolbar"> <div class="toolbar">
<select id="cierre-period-filter" class="select-filter" title="Período a cerrar (próximamente)"> <select class="select-filter">
<option>Seleccionar período</option> <option>Marzo 2026</option>
<option>Febrero 2026 (cerrado)</option>
<option>Enero 2026 (cerrado)</option>
</select> </select>
<div class="toolbar__spacer"></div> <div class="toolbar__spacer"></div>
<button id="cierre-run-btn" class="btn btn--primary" onclick="Accounting.runPeriodClose()"> <button class="btn btn--primary">
<svg viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg> <svg viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
Ejecutar Cierre Ejecutar Cierre
</button> </button>
@@ -476,7 +478,7 @@
</div> </div>
<div id="entryLines"> <div id="entryLines">
<div class="entry-line" style="display:grid;grid-template-columns:2fr 1fr 1fr auto;gap:var(--space-2);margin-bottom:var(--space-2);align-items:center;"> <div class="entry-line" style="display:grid;grid-template-columns:2fr 1fr 1fr auto;gap:var(--space-2);margin-bottom:var(--space-2);align-items:center;">
<select class="entry-account" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);"><option value="">Cargando cuentas...</option></select> <input type="text" placeholder="Cuenta contable" class="entry-account" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);" />
<input type="number" placeholder="Debe" class="entry-debit" step="0.01" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);" /> <input type="number" placeholder="Debe" class="entry-debit" step="0.01" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);" />
<input type="number" placeholder="Haber" class="entry-credit" step="0.01" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);" /> <input type="number" placeholder="Haber" class="entry-credit" step="0.01" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);" />
<button class="btn btn--ghost btn--sm" onclick="this.closest('.entry-line').remove()">&times;</button> <button class="btn btn--ghost btn--sm" onclick="this.closest('.entry-line').remove()">&times;</button>
@@ -492,12 +494,12 @@
</div> </div>
</div> </div>
<script src="/pos/static/js/i18n.js?v=39" defer></script> <script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/app-init.js?v=8" defer></script> <script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script> <script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script> <script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js?v=46" defer></script> <script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/accounting.v9.js?v=33" defer></script> <script src="/pos/static/js/accounting.js" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script> <script src="/pos/static/js/sync-engine.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script> <script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
<script src="/pos/static/js/pwa-install.js" defer></script> <script src="/pos/static/js/pwa-install.js" defer></script>

View File

@@ -7,7 +7,7 @@
<title>Catalogo — Nexus Autoparts POS</title> <title>Catalogo — Nexus Autoparts POS</title>
<link rel="stylesheet" href="/pos/static/css/tokens.css" /> <link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" /> <link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" /> <link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" /> <link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" /> <link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="stylesheet" href="/pos/static/css/chat.css" /> <link rel="stylesheet" href="/pos/static/css/chat.css" />
@@ -17,7 +17,8 @@
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" /> <link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
<script src="/pos/static/js/native-bridge.js" defer></script> <script src="/pos/static/js/native-bridge.js" defer></script>
<link rel="stylesheet" href="/pos/static/css/catalog.css"></head> <link rel="stylesheet" href="/pos/static/css/catalog.css">
</head>
<body> <body>
@@ -123,7 +124,7 @@
</div> </div>
<div class="search-bar" id="searchBar"> <div class="search-bar" id="searchBar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
<input type="text" id="searchInput" placeholder="Buscar por numero de parte o nombre... (F1)" autocomplete="off" aria-label="Buscar productos" /> <input type="text" id="searchInput" placeholder="Buscar por numero de parte o nombre... (F1)" autocomplete="off" />
<button type="button" id="btnScanBarcode" title="Escanear codigo de barras" style="background:none;border:none;cursor:pointer;padding:4px 8px;color:var(--color-text-muted);display:flex;align-items:center;" onclick="CatalogApp.startBarcodeScan()"> <button type="button" id="btnScanBarcode" title="Escanear codigo de barras" style="background:none;border:none;cursor:pointer;padding:4px 8px;color:var(--color-text-muted);display:flex;align-items:center;" onclick="CatalogApp.startBarcodeScan()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7V5a2 2 0 012-2h2"/><path d="M17 3h2a2 2 0 012 2v2"/><path d="M21 17v2a2 2 0 01-2 2h-2"/><path d="M7 21H5a2 2 0 01-2-2v-2"/><line x1="7" y1="12" x2="17" y2="12"/><line x1="7" y1="8" x2="17" y2="8"/><line x1="7" y1="16" x2="17" y2="16"/></svg> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7V5a2 2 0 012-2h2"/><path d="M17 3h2a2 2 0 012 2v2"/><path d="M21 17v2a2 2 0 01-2 2h-2"/><path d="M7 21H5a2 2 0 01-2-2v-2"/><line x1="7" y1="12" x2="17" y2="12"/><line x1="7" y1="8" x2="17" y2="8"/><line x1="7" y1="16" x2="17" y2="16"/></svg>
</button> </button>
@@ -194,7 +195,7 @@
<!-- Level title + optional filter --> <!-- Level title + optional filter -->
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-4); flex-wrap:wrap;"> <div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-4); flex-wrap:wrap;">
<h2 class="level-title" id="levelTitle">Selecciona una marca</h2> <h2 class="level-title" id="levelTitle">Selecciona una marca</h2>
<input type="text" class="level-filter" id="levelFilter" placeholder="Filtrar..." style="display:none;" aria-label="Filtrar niveles de catalogo" /> <input type="text" class="level-filter" id="levelFilter" placeholder="Filtrar..." style="display:none;" />
</div> </div>
<!-- Loading spinner --> <!-- Loading spinner -->
@@ -314,17 +315,17 @@
</div> </div>
</div> </div>
<script src="/pos/static/js/i18n.js?v=39" defer></script> <script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/kiosk.js" defer></script> <script src="/pos/static/js/kiosk.js" defer></script>
<script src="/pos/static/js/app-init.js?v=8" defer></script> <script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script> <script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script> <script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js?v=46" defer></script> <script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/catalog.js?v=33" defer></script> <script src="/pos/static/js/catalog.js?v=8" defer></script>
<script src="/pos/static/js/offline-banner.js" defer></script> <script src="/pos/static/js/offline-banner.js" defer></script>
<script src="/pos/static/js/chat.js" defer></script> <script src="/pos/static/js/chat.js" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script> <script src="/pos/static/js/sync-engine.js" defer></script>
<script src="/pos/static/js/onboarding.js?v=33" defer></script> <script src="/pos/static/js/onboarding.js?v=2" defer></script>
<script> <script>
if('serviceWorker' in navigator){ if('serviceWorker' in navigator){
navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'}).then(function(reg){ navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'}).then(function(reg){
@@ -340,6 +341,6 @@
} }
</script> </script>
<script src="/pos/static/js/pwa-install.js" defer></script> <script src="/pos/static/js/pwa-install.js" defer></script>
<script src="/pos/static/js/brand-catalog.js?v=33" defer></script> <script src="/pos/static/js/brand-catalog.js?v=10" defer></script>
</body> </body>
</html> </html>

View File

@@ -8,45 +8,14 @@
<link rel="stylesheet" href="/pos/static/css/chat.css" /> <link rel="stylesheet" href="/pos/static/css/chat.css" />
<link rel="stylesheet" href="/pos/static/css/tokens.css" /> <link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" /> <link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" /> <link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" /> <link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" /> <link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" /> <link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" /> <meta name="theme-color" content="#F5A623" />
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" /> <link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
<link rel="stylesheet" href="/pos/static/css/config.css?v=34"> <link rel="stylesheet" href="/pos/static/css/config.css?v=2">
<style>
.cfg-tabs {
display: flex;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border);
background: var(--color-surface-1);
position: sticky;
top: 0;
z-index: 10;
flex-wrap: wrap;
}
.cfg-tab-btn {
padding: var(--space-2) var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-2);
color: var(--color-text-secondary);
cursor: pointer;
font-size: var(--text-body-sm);
font-weight: var(--font-weight-medium);
}
.cfg-tab-btn:hover { background: var(--color-surface-3); }
.cfg-tab-btn.active {
background: var(--color-accent);
color: #fff;
border-color: var(--color-accent);
}
.settings-section[data-tab] { display: none !important; }
.settings-section[data-tab].active { display: block !important; }
</style>
</head> </head>
<body> <body>
@@ -161,7 +130,7 @@
<h1 class="page-header__title">Configuración</h1> <h1 class="page-header__title">Configuración</h1>
</div> </div>
<div class="page-header__actions"> <div class="page-header__actions">
<button id="btn-save-all" class="btn btn--primary" type="button"> <button class="btn btn--primary">
<svg viewBox="0 0 24 24"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg> <svg viewBox="0 0 24 24"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
Guardar Cambios Guardar Cambios
</button> </button>
@@ -170,20 +139,11 @@
<!-- Scrollable Content --> <!-- Scrollable Content -->
<div class="content-scroll"> <div class="content-scroll">
<!-- Config tabs -->
<div class="cfg-tabs" id="configTabs">
<button class="cfg-tab-btn active" data-tab="general" onclick="Config.switchTab('general')">General</button>
<button class="cfg-tab-btn" data-tab="fiscal" onclick="Config.switchTab('fiscal')">Fiscal</button>
<button class="cfg-tab-btn" data-tab="catalog" onclick="Config.switchTab('catalog')">Catálogo</button>
<button class="cfg-tab-btn" data-tab="employees" onclick="Config.switchTab('employees')">Empleados</button>
<button class="cfg-tab-btn cfg-tab-btn--permissions" data-tab="permissions" onclick="Config.switchTab('permissions')" style="display:none;">Permisos</button>
</div>
<!-- =============================================================== <!-- ===============================================================
SECTION 1: APARIENCIA / TEMA SECTION 1: APARIENCIA / TEMA
=============================================================== --> =============================================================== -->
<div class="settings-section" data-tab="general"> <div class="settings-section">
<div class="settings-section__header"> <div class="settings-section__header">
<div class="settings-section__icon"> <div class="settings-section__icon">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg> <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
@@ -246,7 +206,7 @@
<!-- =============================================================== <!-- ===============================================================
SECTION 2: DATOS DE LA EMPRESA SECTION 2: DATOS DE LA EMPRESA
=============================================================== --> =============================================================== -->
<div class="settings-section" data-tab="general"> <div class="settings-section">
<div class="settings-section__header"> <div class="settings-section__header">
<div class="settings-section__icon"> <div class="settings-section__icon">
<svg viewBox="0 0 24 24"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg> <svg viewBox="0 0 24 24"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
@@ -295,88 +255,9 @@
</div> </div>
<!-- =============================================================== <!-- ===============================================================
SECTION 3: PERSONALIZACIÓN DE TICKET SECTION 3: MÓDULOS E INTEGRACIONES
=============================================================== --> =============================================================== -->
<div class="settings-section" data-tab="general"> <div class="settings-section">
<div class="settings-section__header">
<div class="settings-section__icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
</div>
<div>
<div class="settings-section__title">Personalización de Ticket</div>
<div class="settings-section__desc">Logo, datos y opciones que aparecen en el ticket de venta</div>
</div>
</div>
<div class="settings-card">
<div class="form-grid">
<div class="form-group form-group--full">
<label class="form-label">Logo del negocio</label>
<input type="file" id="receipt-logo" accept="image/png,image/jpeg,image/jpg,image/webp" onchange="Config.handleReceiptLogo(this)" style="display:none;" />
<div id="receipt-logo-preview" style="display:flex;align-items:center;gap:var(--space-3);flex-wrap:wrap;">
<div id="receipt-logo-thumb" style="width:80px;height:80px;border:1px dashed var(--color-border);border-radius:var(--radius-md);display:flex;align-items:center;justify-content:center;overflow:hidden;background:var(--color-surface-2);">
<span style="color:var(--color-text-muted);font-size:var(--text-caption);text-align:center;padding:var(--space-2);">Sin logo</span>
</div>
<button class="btn btn--secondary btn--sm" onclick="document.getElementById('receipt-logo').click()">Subir imagen</button>
<button class="btn btn--ghost btn--sm" id="receipt-logo-remove" onclick="Config.removeReceiptLogo()" style="display:none;">Quitar</button>
</div>
<div class="form-hint">Recomendado: PNG/JPG con fondo blanco o transparente, máx. 300x150 px.</div>
</div>
<div class="form-group">
<label class="form-label">Nombre en ticket</label>
<input class="form-input" id="receipt-store-name" type="text" placeholder="Ej: Refacciones El Toro" />
</div>
<div class="form-group">
<label class="form-label">Slogan / Línea debajo del nombre</label>
<input class="form-input" id="receipt-tagline" type="text" placeholder="Ej: Tu conexion con las refacciones" />
</div>
<div class="form-group">
<label class="form-label">RFC en ticket</label>
<input class="form-input" id="receipt-rfc" type="text" placeholder="Ej: RET260101ABC" maxlength="13" style="text-transform:uppercase;" />
</div>
<div class="form-group form-group--full">
<label class="form-label">Dirección en ticket</label>
<input class="form-input" id="receipt-address" type="text" placeholder="Calle, Número, Colonia, CP, Ciudad" />
</div>
<div class="form-group">
<label class="form-label">Teléfono en ticket</label>
<input class="form-input" id="receipt-phone" type="tel" placeholder="Ej: 664-123-4567" />
</div>
<div class="form-group">
<label class="form-label">Ancho del papel</label>
<select class="form-select" id="receipt-paper-width">
<option value="58">58 mm (térmica pequeña)</option>
<option value="80" selected>80 mm (térmica estándar)</option>
</select>
</div>
<div class="form-group form-group--full">
<label class="form-label">Mensaje de agradecimiento</label>
<input class="form-input" id="receipt-thanks" type="text" placeholder="Gracias por su compra!" />
</div>
<div class="form-group form-group--full">
<label class="form-label">Pie de página adicional</label>
<input class="form-input" id="receipt-footer" type="text" placeholder="Ej: Conserve su ticket como comprobante." />
</div>
</div>
<div class="form-grid" style="margin-top:var(--space-4);">
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;"><input type="checkbox" id="receipt-show-logo" style="width:auto;" checked /> Mostrar logo</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;"><input type="checkbox" id="receipt-show-rfc" style="width:auto;" checked /> Mostrar RFC</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;"><input type="checkbox" id="receipt-show-address" style="width:auto;" /> Mostrar dirección</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;"><input type="checkbox" id="receipt-show-phone" style="width:auto;" /> Mostrar teléfono</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;"><input type="checkbox" id="receipt-show-iva" style="width:auto;" checked /> Mostrar desglose de IVA</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;"><input type="checkbox" id="receipt-show-payment" style="width:auto;" checked /> Mostrar detalle de pago</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;"><input type="checkbox" id="receipt-show-employee" style="width:auto;" /> Mostrar nombre del empleado</label>
</div>
<div style="margin-top:var(--space-4);text-align:right;">
<button class="btn btn--primary" onclick="Config.saveReceiptConfig()">Guardar ticket</button>
</div>
</div>
</div>
<!-- ===============================================================
SECTION 4: MÓDULOS E INTEGRACIONES
=============================================================== -->
<div class="settings-section" data-tab="general">
<div class="settings-section__header"> <div class="settings-section__header">
<div class="settings-section__icon"> <div class="settings-section__icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
@@ -428,26 +309,6 @@
<span class="toggle__slider"></span> <span class="toggle__slider"></span>
</label> </label>
</div> </div>
<div class="toggle-row">
<div class="toggle-row__info">
<span class="toggle-row__label">Taller / Servicio</span>
<span class="toggle-row__desc">Mostrar el módulo de órdenes de servicio y taller</span>
</div>
<label class="toggle">
<input type="checkbox" id="cfg-module-workshop" checked />
<span class="toggle__slider"></span>
</label>
</div>
<div class="toggle-row">
<div class="toggle-row__info">
<span class="toggle-row__label">Notas de remisión en mostrador</span>
<span class="toggle-row__desc">Permite generar notas de remisión desde el POS para cobrar posteriormente en caja</span>
</div>
<label class="toggle">
<input type="checkbox" id="cfg-module-counter-remission" />
<span class="toggle__slider"></span>
</label>
</div>
<div style="margin-top:var(--space-4);text-align:right;"> <div style="margin-top:var(--space-4);text-align:right;">
<button class="btn btn--primary" onclick="Config.saveModules()">Guardar módulos</button> <button class="btn btn--primary" onclick="Config.saveModules()">Guardar módulos</button>
</div> </div>
@@ -457,13 +318,13 @@
<!-- =============================================================== <!-- ===============================================================
SECTION 4: USUARIOS Y PERMISOS SECTION 4: USUARIOS Y PERMISOS
=============================================================== --> =============================================================== -->
<div class="settings-section" data-tab="employees"> <div class="settings-section">
<div class="settings-section__header"> <div class="settings-section__header">
<div class="settings-section__icon"> <div class="settings-section__icon">
<svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg> <svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</div> </div>
<div> <div>
<div class="settings-section__title">Empleados</div> <div class="settings-section__title">Usuarios y Permisos</div>
<div class="settings-section__desc">Gestiona quién accede al sistema y qué puede hacer</div> <div class="settings-section__desc">Gestiona quién accede al sistema y qué puede hacer</div>
</div> </div>
</div> </div>
@@ -500,7 +361,7 @@
<!-- =============================================================== <!-- ===============================================================
SECTION 4: IMPRESORAS SECTION 4: IMPRESORAS
=============================================================== --> =============================================================== -->
<div class="settings-section" data-tab="general"> <div class="settings-section">
<div class="settings-section__header"> <div class="settings-section__header">
<div class="settings-section__icon"> <div class="settings-section__icon">
<svg viewBox="0 0 24 24"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg> <svg viewBox="0 0 24 24"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
@@ -526,7 +387,7 @@
<!-- =============================================================== <!-- ===============================================================
SECTION 5: SUCURSALES SECTION 5: SUCURSALES
=============================================================== --> =============================================================== -->
<div class="settings-section" data-tab="fiscal"> <div class="settings-section">
<div class="settings-section__header"> <div class="settings-section__header">
<div class="settings-section__icon"> <div class="settings-section__icon">
<svg viewBox="0 0 24 24"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg> <svg viewBox="0 0 24 24"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
@@ -545,7 +406,7 @@
<!-- =============================================================== <!-- ===============================================================
SECTION 6: PARÁMETROS FISCALES SECTION 6: PARÁMETROS FISCALES
=============================================================== --> =============================================================== -->
<div class="settings-section" data-tab="fiscal"> <div class="settings-section">
<div class="settings-section__header"> <div class="settings-section__header">
<div class="settings-section__icon"> <div class="settings-section__icon">
<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg> <svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
@@ -681,7 +542,7 @@
<!-- =============================================================== <!-- ===============================================================
SECTION 7: PREFERENCIAS DEL SISTEMA SECTION 7: PREFERENCIAS DEL SISTEMA
=============================================================== --> =============================================================== -->
<div class="settings-section" data-tab="general"> <div class="settings-section">
<div class="settings-section__header"> <div class="settings-section__header">
<div class="settings-section__icon"> <div class="settings-section__icon">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg> <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
@@ -753,33 +614,13 @@
<span class="toggle__slider"></span> <span class="toggle__slider"></span>
</label> </label>
</div> </div>
<div class="toggle-row">
<div class="toggle-row__info">
<span class="toggle-row__label">Permitir ventas en $0</span>
<span class="toggle-row__desc">Permite vender artículos con precio unitario o total de línea igual a $0</span>
</div>
<label class="toggle">
<input type="checkbox" id="cfg-allow-zero-price" checked />
<span class="toggle__slider"></span>
</label>
</div>
<div class="toggle-row">
<div class="toggle-row__info">
<span class="toggle-row__label">Permitir venta sin stock</span>
<span class="toggle-row__desc">Permite vender aunque no haya existencias suficientes en la sucursal (stock negativo)</span>
</div>
<label class="toggle">
<input type="checkbox" id="cfg-allow-negative-stock" />
<span class="toggle__slider"></span>
</label>
</div>
</div> </div>
</div> </div>
<!-- =============================================================== <!-- ===============================================================
SECTION 8: VEHICLE COMPATIBILITY SOURCE SECTION 8: VEHICLE COMPATIBILITY SOURCE
=============================================================== --> =============================================================== -->
<div class="settings-section" data-tab="catalog"> <div class="settings-section">
<div class="settings-section__header"> <div class="settings-section__header">
<div class="settings-section__icon"> <div class="settings-section__icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
@@ -812,7 +653,7 @@
<!-- =============================================================== <!-- ===============================================================
SECTION 9: MARCAS DE PARTES PERMITIDAS SECTION 9: MARCAS DE PARTES PERMITIDAS
=============================================================== --> =============================================================== -->
<div class="settings-section" data-tab="catalog"> <div class="settings-section">
<div class="settings-section__header"> <div class="settings-section__header">
<div class="settings-section__icon"> <div class="settings-section__icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><polyline points="2 17 12 22 22 17"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><polyline points="2 17 12 22 22 17"/></svg>
@@ -873,64 +714,6 @@
</div> </div>
</div> </div>
<!-- ===============================================================
SECTION: PERMISOS POR ROL
=============================================================== -->
<div class="settings-section" data-tab="permissions">
<div class="settings-section__header">
<div class="settings-section__icon">
<svg viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
</div>
<div>
<div class="settings-section__title">Permisos por Rol</div>
<div class="settings-section__desc">Define los permisos predeterminados para cada rol del sistema</div>
</div>
</div>
<div class="settings-card">
<div class="form-group" style="max-width:320px;">
<label class="form-label">Rol</label>
<select class="form-input" id="cfg-perm-role" onchange="Config.renderRolePermissions(); Config.renderWorkshopPermissions();">
<option value="">Selecciona un rol</option>
<option value="admin">Administrador</option>
<option value="cashier">Cajero</option>
<option value="counter">Mostrador</option>
<option value="warehouse">Almacén</option>
<option value="accountant">Contador</option>
<option value="workshop">Taller</option>
<option value="mechanic">Mecánico</option>
</select>
</div>
<!-- Permission tabs -->
<div class="cfg-tabs" style="margin-top:var(--space-4);position:static;">
<button type="button" class="cfg-tab-btn active" id="tab-perm-modules" onclick="Config.switchPermTab('modules')">Módulos</button>
<button type="button" class="cfg-tab-btn" id="tab-perm-workshop" onclick="Config.switchPermTab('workshop')">Taller</button>
</div>
<div id="perm-panel-modules" class="perm-panel active" style="margin-top:var(--space-4);">
<div id="role-permissions-container">
<p style="color:var(--color-text-muted);">Selecciona un rol para ver y editar sus permisos.</p>
</div>
<div style="margin-top:var(--space-4);">
<button class="btn btn--primary" id="btn-save-role-permissions" onclick="Config.saveRolePermissions()">Guardar permisos del rol</button>
<span id="role-permissions-status" style="font-size:var(--text-caption);color:var(--color-text-muted);margin-left:var(--space-3);"></span>
</div>
</div>
<div id="perm-panel-workshop" class="perm-panel" style="margin-top:var(--space-4);display:none;">
<div id="workshop-permissions-container">
<p style="color:var(--color-text-muted);">Selecciona un rol para ver y editar sus permisos de Taller.</p>
</div>
<div style="margin-top:var(--space-4);">
<button class="btn btn--primary" id="btn-save-workshop-permissions" onclick="Config.saveWorkshopPermissions()">Guardar permisos de Taller</button>
<span id="workshop-permissions-status" style="font-size:var(--text-caption);color:var(--color-text-muted);margin-left:var(--space-3);"></span>
</div>
</div>
</div>
</div>
</div><!-- /content-scroll --> </div><!-- /content-scroll -->
</main> </main>
</div><!-- /app-shell --> </div><!-- /app-shell -->
@@ -1039,11 +822,8 @@
<option value="">-- Seleccionar --</option> <option value="">-- Seleccionar --</option>
<option value="admin">Administrador</option> <option value="admin">Administrador</option>
<option value="cashier">Cajero</option> <option value="cashier">Cajero</option>
<option value="counter">Mostrador</option>
<option value="warehouse">Almacenista</option> <option value="warehouse">Almacenista</option>
<option value="accountant">Contador</option> <option value="accountant">Contador</option>
<option value="workshop">Taller</option>
<option value="mechanic">Mecanico</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -1069,13 +849,13 @@
</div> </div>
</div> </div>
<script src="/pos/static/js/i18n.js?v=39" defer></script> <script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/app-init.js?v=8" defer></script> <script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script> <script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script> <script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js?v=46" defer></script> <script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/kiosk.js" defer></script> <script src="/pos/static/js/kiosk.js" defer></script>
<script src="/pos/static/js/config.js?v=41" defer></script> <script src="/pos/static/js/config.js?v=3" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script> <script src="/pos/static/js/sync-engine.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script> <script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
<script src="/pos/static/js/pwa-install.js" defer></script> <script src="/pos/static/js/pwa-install.js" defer></script>

View File

@@ -8,14 +8,15 @@
<link rel="stylesheet" href="/pos/static/css/chat.css" /> <link rel="stylesheet" href="/pos/static/css/chat.css" />
<link rel="stylesheet" href="/pos/static/css/tokens.css" /> <link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" /> <link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" /> <link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" /> <link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" /> <link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" /> <link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" /> <meta name="theme-color" content="#F5A623" />
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" /> <link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
<link rel="stylesheet" href="/pos/static/css/customers.css?v=34"></head> <link rel="stylesheet" href="/pos/static/css/customers.css">
</head>
<body> <body>
@@ -297,15 +298,15 @@
<circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5L14 14"/> <circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5L14 14"/>
</svg> </svg>
</span> </span>
<input type="text" class="search-input" placeholder="Buscar por nombre, RFC, teléfono…" id="searchInput" oninput="filterCustomers()" aria-label="Buscar clientes" /> <input type="text" class="search-input" placeholder="Buscar por nombre, RFC, teléfono…" id="searchInput" oninput="filterCustomers()" />
</div> </div>
<select class="filter-select" onchange="filterCustomers()" id="tipoFilter" aria-label="Filtrar por tipo de cliente"> <select class="filter-select" onchange="filterCustomers()" id="tipoFilter">
<option value="">Todos los tipos</option> <option value="">Todos los tipos</option>
<option value="Taller">Taller</option> <option value="Taller">Taller</option>
<option value="Mostrador">Mostrador</option> <option value="Mostrador">Mostrador</option>
<option value="Mayoreo">Mayoreo</option> <option value="Mayoreo">Mayoreo</option>
</select> </select>
<select class="filter-select" onchange="filterCustomers()" id="estadoFilter" aria-label="Filtrar por estado de cliente"> <select class="filter-select" onchange="filterCustomers()" id="estadoFilter">
<option value="">Todos los estados</option> <option value="">Todos los estados</option>
<option value="Activo">Activo</option> <option value="Activo">Activo</option>
<option value="Inactivo">Inactivo</option> <option value="Inactivo">Inactivo</option>
@@ -341,7 +342,15 @@
<!-- Pagination --> <!-- Pagination -->
<div class="table-footer"> <div class="table-footer">
<div class="table-footer__info" id="tableInfo"></div> <div class="table-footer__info" id="tableInfo"></div>
<div class="pagination" id="customersPagination"></div> <div class="pagination">
<button class="page-btn">&#8249;</button>
<button class="page-btn active">1</button>
<button class="page-btn">2</button>
<button class="page-btn">3</button>
<span style="color:var(--color-text-muted);font-size:var(--text-caption);padding:0 4px;"></span>
<button class="page-btn">86</button>
<button class="page-btn">&#8250;</button>
</div>
</div> </div>
</div> </div>
@@ -366,12 +375,12 @@
<!-- Header --> <!-- Header -->
<div class="detail-header"> <div class="detail-header">
<div class="customer-avatar" id="detailAvatar"></div> <div class="customer-avatar" id="detailAvatar">MA</div>
<div class="detail-header__info"> <div class="detail-header__info">
<div class="detail-header__name" id="detailName"></div> <div class="detail-header__name" id="detailName">MIGUEL ÁNGEL TORRES</div>
<div class="detail-header__rfc" id="detailRFC"></div> <div class="detail-header__rfc" id="detailRFC">TOAM820115HDF</div>
<div class="detail-header__meta"> <div class="detail-header__meta">
<span class="tipo-chip tipo-chip--taller" id="detailTipo"></span> <span class="tipo-chip tipo-chip--taller" id="detailTipo">Taller</span>
<span class="badge badge--active" id="detailStatus"><span class="badge-dot"></span>Activo</span> <span class="badge badge--active" id="detailStatus"><span class="badge-dot"></span>Activo</span>
</div> </div>
</div> </div>
@@ -392,7 +401,7 @@
</div> </div>
<div class="info-row info-row--full"> <div class="info-row info-row--full">
<span class="info-label">Dirección</span> <span class="info-label">Dirección</span>
<span class="info-value" id="detailAddress"></span> <span class="info-value" id="detailAddress">Av. Insurgentes Sur 1602, Col. Crédito Constructor, CDMX</span>
</div> </div>
<div class="info-row"> <div class="info-row">
<span class="info-label">CP</span> <span class="info-label">CP</span>
@@ -451,14 +460,6 @@
</div> </div>
</div> </div>
<!-- Vehicles -->
<div class="detail-section">
<div class="detail-section__title">Veh&iacute;culos</div>
<div id="detailVehicles" style="display:flex;flex-direction:column;gap:var(--space-2);">
<span style="color:var(--color-text-muted);">Sin veh&iacute;culos registrados</span>
</div>
</div>
<!-- Quick Actions --> <!-- Quick Actions -->
<div class="detail-section"> <div class="detail-section">
<div class="detail-section__title">Acciones Rápidas</div> <div class="detail-section__title">Acciones Rápidas</div>
@@ -497,15 +498,6 @@
</span> </span>
Historial Historial
</button> </button>
<button class="action-btn action-btn--danger" id="btnDeleteCustomer" onclick="deleteCustomer()">
<span class="action-btn__icon">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<polyline points="3 6 5 6 13 6"/><path d="M5 6v7a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2V6"/>
<line x1="7" y1="3" x2="9" y2="3"/><line x1="4" y1="3" x2="14" y2="3"/>
</svg>
</span>
Eliminar
</button>
</div> </div>
</div> </div>
@@ -650,13 +642,13 @@
</div> </div>
</div> </div>
<script src="/pos/static/js/i18n.js?v=39" defer></script> <script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/app-init.js?v=8" defer></script> <script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script> <script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script> <script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js?v=46" defer></script> <script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/virtual-scroll.js" defer></script> <script src="/pos/static/js/virtual-scroll.js" defer></script>
<script src="/pos/static/js/customers.js?v=35" defer></script> <script src="/pos/static/js/customers.js?v=2" defer></script>
<script src="/pos/static/js/offline-banner.js" defer></script> <script src="/pos/static/js/offline-banner.js" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script> <script src="/pos/static/js/sync-engine.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script> <script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>

View File

@@ -8,14 +8,15 @@
<link rel="stylesheet" href="/pos/static/css/chat.css" /> <link rel="stylesheet" href="/pos/static/css/chat.css" />
<link rel="stylesheet" href="/pos/static/css/tokens.css" /> <link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" /> <link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" /> <link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" /> <link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" /> <link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" /> <link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" /> <meta name="theme-color" content="#F5A623" />
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" /> <link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
<link rel="stylesheet" href="/pos/static/css/dashboard.css?v=33"></head> <link rel="stylesheet" href="/pos/static/css/dashboard.css?v=3">
</head>
<body> <body>
@@ -158,16 +159,6 @@
Ventas Históricas Ventas Históricas
</a> </a>
<a href="/pos/remission-notes" class="nav-link">
<span class="nav-link__icon">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M2 3h12v10H2z"/>
<path d="M5 7h6M5 10h4" stroke-width="1.2"/>
</svg>
</span>
Notas de Remisión
</a>
<div class="sidebar__section-label">Gestión</div> <div class="sidebar__section-label">Gestión</div>
<a href="/pos/marketplace" class="nav-link"> <a href="/pos/marketplace" class="nav-link">
@@ -520,40 +511,6 @@
</div><!-- end alerts-grid --> </div><!-- end alerts-grid -->
</section> </section>
<!-- =================================================================
CRÉDITOS POR VENCER
================================================================= -->
<section>
<div class="table-card">
<div class="table-header">
<div>
<div class="section-title">Créditos por Cobrar</div>
<div style="font-size:var(--text-caption);color:var(--color-text-muted);margin-top:2px;" id="credit-alerts-meta">Vencidos: -- / Por vencer: --</div>
</div>
<a href="/pos/accounting" class="section-action" style="text-decoration:none;color:inherit;">Ir a contabilidad &rarr;</a>
</div>
<div class="table-wrap themed-scrollbar">
<table>
<thead>
<tr>
<th>Cliente</th>
<th>Folio</th>
<th>Vencimiento</th>
<th>Días</th>
<th class="align-right">Saldo</th>
<th>Estado</th>
</tr>
</thead>
<tbody id="credit-alerts-tbody">
<tr><td colspan="6"><div class="skeleton skeleton--text" style="width:100%;"></div></td></tr>
<tr><td colspan="6"><div class="skeleton skeleton--text" style="width:80%;"></div></td></tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- ================================================================= <!-- =================================================================
RECENT SALES TABLE RECENT SALES TABLE
================================================================= --> ================================================================= -->
@@ -605,15 +562,15 @@
<script src="/pos/static/js/chart.umd.min.js" defer></script> <script src="/pos/static/js/chart.umd.min.js" defer></script>
<script src="/pos/static/js/i18n.js?v=39" defer></script> <script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/app-init.js?v=8" defer></script> <script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script> <script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script> <script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js?v=46" defer></script> <script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/dashboard-stats.js?v=33" defer></script> <script src="/pos/static/js/dashboard-stats.js?v=3" defer></script>
<script src="/pos/static/js/dashboard.js?v=33" defer></script> <script src="/pos/static/js/dashboard.js?v=7" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script> <script src="/pos/static/js/sync-engine.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js?v=34',{scope:'/pos/'});}</script> <script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
<script src="/pos/static/js/pwa-install.js" defer></script> <script src="/pos/static/js/pwa-install.js" defer></script>
<script src="/pos/static/js/chat.js" defer></script> <script src="/pos/static/js/chat.js" defer></script>

View File

@@ -8,7 +8,7 @@
<link rel="stylesheet" href="/pos/static/css/chat.css" /> <link rel="stylesheet" href="/pos/static/css/chat.css" />
<link rel="stylesheet" href="/pos/static/css/tokens.css" /> <link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" /> <link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" /> <link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" /> <link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" /> <link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="stylesheet" href="/pos/static/css/onboarding.css" /> <link rel="stylesheet" href="/pos/static/css/onboarding.css" />
@@ -17,7 +17,8 @@
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" /> <link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
<script src="/pos/static/js/native-bridge.js" defer></script> <script src="/pos/static/js/native-bridge.js" defer></script>
<link rel="stylesheet" href="/pos/static/css/diagrams.css"></head> <link rel="stylesheet" href="/pos/static/css/diagrams.css">
</head>
<body> <body>
<div class="app-shell"> <div class="app-shell">
@@ -148,12 +149,12 @@
</main> </main>
</div> </div>
<script src="/pos/static/js/i18n.js?v=39" defer></script> <script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/kiosk.js" defer></script> <script src="/pos/static/js/kiosk.js" defer></script>
<script src="/pos/static/js/app-init.js?v=8" defer></script> <script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script> <script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script> <script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js?v=46" defer></script> <script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/diagrams.js" defer></script> <script src="/pos/static/js/diagrams.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script> <script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
<script src="/pos/static/js/pwa-install.js" defer></script> <script src="/pos/static/js/pwa-install.js" defer></script>

View File

@@ -8,14 +8,15 @@
<link rel="stylesheet" href="/pos/static/css/chat.css" /> <link rel="stylesheet" href="/pos/static/css/chat.css" />
<link rel="stylesheet" href="/pos/static/css/tokens.css" /> <link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" /> <link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" /> <link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" /> <link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" /> <link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" /> <link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" /> <meta name="theme-color" content="#F5A623" />
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" /> <link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
<link rel="stylesheet" href="/pos/static/css/fleet.css"></head> <link rel="stylesheet" href="/pos/static/css/fleet.css">
</head>
<body> <body>
<div class="page-shell"> <div class="page-shell">
@@ -302,11 +303,11 @@
</div> </div>
</div> </div>
<script src="/pos/static/js/i18n.js?v=39" defer></script> <script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/app-init.js?v=8" defer></script> <script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script> <script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script> <script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js?v=46" defer></script> <script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/fleet.js" defer></script> <script src="/pos/static/js/fleet.js" defer></script>
<script src="/pos/static/js/offline-banner.js" defer></script> <script src="/pos/static/js/offline-banner.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script> <script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>

View File

@@ -34,7 +34,8 @@
.pagination button:disabled { opacity: 0.5; cursor: not-allowed; } .pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
.pagination span { font-size: 13px; color: #4b5563; } .pagination span { font-size: 13px; color: #4b5563; }
.loading { text-align: center; padding: 40px; color: #6b7280; } .loading { text-align: center; padding: 40px; color: #6b7280; }
</style></head> </style>
</head>
<body> <body>
<div class="header"> <div class="header">
<h1>📊 Ventas Históricas - Atlas</h1> <h1>📊 Ventas Históricas - Atlas</h1>

View File

@@ -8,14 +8,15 @@
<link rel="stylesheet" href="/pos/static/css/chat.css" /> <link rel="stylesheet" href="/pos/static/css/chat.css" />
<link rel="stylesheet" href="/pos/static/css/tokens.css" /> <link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" /> <link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" /> <link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" /> <link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" /> <link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" /> <link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" /> <meta name="theme-color" content="#F5A623" />
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" /> <link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
<link rel="stylesheet" href="/pos/static/css/inventory.css?v=34"></head> <link rel="stylesheet" href="/pos/static/css/inventory.css?v=8">
</head>
<body> <body>
@@ -192,7 +193,7 @@
<h1 class="page-header__title">Inventario</h1> <h1 class="page-header__title">Inventario</h1>
</div> </div>
<div class="page-header__actions"> <div class="page-header__actions">
<button class="btn btn--ghost" id="btnHeaderImport" onclick="document.getElementById('bulkImportModal').classList.add('is-open')"> <button class="btn btn--ghost" onclick="document.getElementById('bulkImportModal').classList.add('is-open')">
<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> <svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Importar CSV Importar CSV
</button> </button>
@@ -204,7 +205,7 @@
<svg viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-.38-4.93"/></svg> <svg viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-.38-4.93"/></svg>
Sincronizar Sincronizar
</button> </button>
<button class="btn btn--primary" id="btnHeaderNewProduct" onclick="showCreateModal()"> <button class="btn btn--primary" onclick="showCreateModal()">
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> <svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Nuevo Producto Nuevo Producto
</button> </button>
@@ -336,7 +337,7 @@
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="6" x2="12" y2="12"/><line x1="16.24" y1="16.24" x2="12" y2="12"/></svg> <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="6" x2="12" y2="12"/><line x1="16.24" y1="16.24" x2="12" y2="12"/></svg>
<span id="tierDiscountBadge">Taller -15% · Mayoreo -25%</span> <span id="tierDiscountBadge">Taller -15% · Mayoreo -25%</span>
</button> </button>
<button class="btn btn--primary btn--sm" id="btnStockNewProduct" onclick="showCreateModal()"> <button class="btn btn--primary btn--sm" onclick="showCreateModal()">
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> <svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Nuevo Producto Nuevo Producto
</button> </button>
@@ -710,48 +711,42 @@
<!-- ===== MODALS ===== --> <!-- ===== MODALS ===== -->
<!-- Create/Edit Item Modal --> <!-- Create Item Modal -->
<div class="inv-modal-overlay" id="createModal"> <div class="inv-modal-overlay" id="createModal">
<div class="inv-modal"> <div class="inv-modal">
<div class="inv-modal__header"> <div class="inv-modal__header">
<h3 id="createModalTitle">Nuevo Producto</h3> <h3>Nuevo Producto</h3>
<button class="inv-modal__close" onclick="closeCreateModal()">&times;</button> <button class="inv-modal__close" onclick="closeCreateModal()">&times;</button>
</div> </div>
<div class="inv-modal__body"> <div class="inv-modal__body">
<input type="hidden" id="editItemId" />
<div class="inv-form-grid"> <div class="inv-form-grid">
<div class="inv-field"><label>No. Parte *</label><input type="text" id="newPartNumber" placeholder="Ej: GAT-50104" /></div> <div class="inv-field"><label>No. Parte *</label><input type="text" id="newPartNumber" placeholder="Ej: GAT-50104" /></div>
<div class="inv-field"><label>Nombre *</label><input type="text" id="newName" placeholder="Nombre del producto" /></div> <div class="inv-field"><label>Nombre *</label><input type="text" id="newName" placeholder="Nombre del producto" /></div>
<div class="inv-field"><label>Marca</label><input type="text" id="newBrand" placeholder="Marca del fabricante" /></div> <div class="inv-field"><label>Marca</label><input type="text" id="newBrand" placeholder="Marca del fabricante" /></div>
<div class="inv-field"><label>Categoría</label> <div class="inv-field"><label>Categoría</label>
<select class="select-filter" id="newCategory" style="width:100%;"> <select class="select-filter" id="newCategory" onchange="onCategoryChange(this.value)" style="width:100%;">
<option value="">Sin categoría</option> <option value="">Selecciona categoría</option>
</select>
</div>
<div class="inv-field"><label>Subcategoría</label>
<select class="select-filter" id="newSubcategory" style="width:100%;" disabled>
<option value="">Selecciona categoría primero</option>
</select> </select>
</div> </div>
<div class="inv-field"><label>Barcode</label><input type="text" id="newBarcode" placeholder="Auto-generado si vacío" /></div> <div class="inv-field"><label>Barcode</label><input type="text" id="newBarcode" placeholder="Auto-generado si vacío" /></div>
<div class="inv-field"><label>SKU Alternativo 1</label><input type="text" id="newSku2" placeholder="Ej: SKU-Bodega-A" /></div> <div class="inv-field"><label>SKU Alternativo 1</label><input type="text" id="newSku2" placeholder="Ej: SKU-Bodega-A" /></div>
<div class="inv-field"><label>SKU Alternativo 2</label><input type="text" id="newSku3" placeholder="Ej: SKU-Bodega-B" /></div> <div class="inv-field"><label>SKU Alternativo 2</label><input type="text" id="newSku3" placeholder="Ej: SKU-Bodega-B" /></div>
<div class="inv-field"><label>Unidad</label><input type="text" id="newUnit" placeholder="pza, kit, lt..." /></div> <div class="inv-field"><label>Costo</label><input type="number" id="newCost" step="0.01" placeholder="0.00" /></div>
<div class="inv-field price-field"><label>Costo</label><input type="number" id="newCost" step="0.01" placeholder="0.00" /></div> <div class="inv-field"><label>Precio Mostrador</label><input type="number" id="newPrice1" step="0.01" placeholder="0.00" /></div>
<div class="inv-field price-field"><label>Precio Mostrador</label><input type="number" id="newPrice1" step="0.01" placeholder="0.00" /></div>
<div class="inv-field"><label>Stock Mínimo</label><input type="number" id="newMinStock" placeholder="0" /></div> <div class="inv-field"><label>Stock Mínimo</label><input type="number" id="newMinStock" placeholder="0" /></div>
<div class="inv-field" id="initialStockField"><label>Stock Inicial</label><input type="number" id="newInitialStock" placeholder="0" /></div> <div class="inv-field"><label>Stock Inicial</label><input type="number" id="newInitialStock" placeholder="0" /></div>
<div class="inv-field"><label>Stock Máximo</label><input type="number" id="newMaxStock" placeholder="0" /></div>
<div class="inv-field"><label>Impuesto (%)</label><input type="number" id="newTaxRate" step="0.01" placeholder="0.00" /></div>
<div class="inv-field"><label>Ubicación</label><input type="text" id="newLocation" placeholder="Ej: A-12-3" /></div> <div class="inv-field"><label>Ubicación</label><input type="text" id="newLocation" placeholder="Ej: A-12-3" /></div>
<div class="inv-field"><label>Estado</label>
<select class="select-filter" id="newIsActive" style="width:100%;">
<option value="true">Activo</option>
<option value="false">Inactivo</option>
</select>
</div>
<div class="inv-field inv-field--full"><label>Descripción</label><textarea id="newDescription" rows="2" placeholder="Descripción del producto"></textarea></div>
</div> </div>
<div id="createResult" style="margin-top:var(--space-3);min-height:1.5em;"></div> <div id="createResult" style="margin-top:var(--space-3);min-height:1.5em;"></div>
</div> </div>
<div class="inv-modal__footer"> <div class="inv-modal__footer">
<button class="btn btn--ghost" onclick="closeCreateModal()">Cancelar</button> <button class="btn btn--ghost" onclick="closeCreateModal()">Cancelar</button>
<button class="btn btn--primary" id="createModalBtn" onclick="createItem()">Crear Producto</button> <button class="btn btn--primary" onclick="createItem()">Crear Producto</button>
</div> </div>
</div> </div>
</div> </div>
@@ -1018,9 +1013,6 @@
<button class="inv-modal__close" onclick="document.getElementById('bulkImportModal').classList.remove('is-open')">&times;</button> <button class="inv-modal__close" onclick="document.getElementById('bulkImportModal').classList.remove('is-open')">&times;</button>
</div> </div>
<div class="inv-modal__body"> <div class="inv-modal__body">
<div style="margin-bottom:12px;">
<button class="btn btn--ghost btn--sm" onclick="downloadBulkImportTemplate()" type="button">📥 Descargar plantilla CSV</button>
</div>
<div style="margin-bottom:12px;"> <div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:4px;font-size:var(--text-caption);color:var(--color-text-muted);">Archivo CSV o Excel</label> <label style="display:block;margin-bottom:4px;font-size:var(--text-caption);color:var(--color-text-muted);">Archivo CSV o Excel</label>
<input type="file" id="bulkImportFile" accept=".csv,.xlsx,.xls" style="width:100%;padding:8px;border:1px dashed var(--color-border);border-radius:6px;background:var(--color-surface);color:var(--color-text);" /> <input type="file" id="bulkImportFile" accept=".csv,.xlsx,.xls" style="width:100%;padding:8px;border:1px dashed var(--color-border);border-radius:6px;background:var(--color-surface);color:var(--color-text);" />
@@ -1042,8 +1034,8 @@
</div> </div>
<div style="font-size:var(--text-caption);color:var(--color-text-muted);background:var(--color-surface);padding:10px;border-radius:6px;"> <div style="font-size:var(--text-caption);color:var(--color-text-muted);background:var(--color-surface);padding:10px;border-radius:6px;">
<strong>Columnas esperadas:</strong> <strong>Columnas esperadas:</strong>
<code style="display:block;margin-top:4px;word-break:break-all;">sku, name, brand, price, stock, cost, sku_secondary, description, category, make, model, year, engine, engine_code</code> <code style="display:block;margin-top:4px;word-break:break-all;">sku, name, brand, price, stock, cost, location, description, category, make, model, year, engine, engine_code</code>
<span style="display:block;margin-top:4px;">También se aceptan sinónimos en español: <em>numero_de_parte, nombre, marca, precio, cantidad, costo, sku_secundario, categoria, fabricante, modelo, anio, motor, codigo_motor</em></span> <span style="display:block;margin-top:4px;">También se aceptan sinónimos en español: <em>numero_de_parte, nombre, marca, precio, cantidad, costo, ubicacion, categoria, fabricante, modelo, anio, motor, codigo_motor</em></span>
</div> </div>
<div id="bulkImportResult" style="margin-top:12px;display:none;"></div> <div id="bulkImportResult" style="margin-top:12px;display:none;"></div>
</div> </div>
@@ -1061,13 +1053,13 @@
<button class="banner__dismiss" onclick="document.getElementById('offlineBanner').style.display='none'" aria-label="Cerrar">&times;</button> <button class="banner__dismiss" onclick="document.getElementById('offlineBanner').style.display='none'" aria-label="Cerrar">&times;</button>
</div> </div>
<script src="/pos/static/js/i18n.js?v=39" defer></script> <script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/app-init.js?v=8" defer></script> <script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script> <script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script> <script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js?v=46" defer></script> <script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/virtual-scroll.js?v=33" defer></script> <script src="/pos/static/js/virtual-scroll.js?v=2" defer></script>
<script src="/pos/static/js/inventory.js?v=38" defer></script> <script src="/pos/static/js/inventory.js?v=18" defer></script>
<script src="/pos/static/js/offline-banner.js" defer></script> <script src="/pos/static/js/offline-banner.js" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script> <script src="/pos/static/js/sync-engine.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script> <script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>

View File

@@ -8,14 +8,15 @@
<link rel="stylesheet" href="/pos/static/css/chat.css" /> <link rel="stylesheet" href="/pos/static/css/chat.css" />
<link rel="stylesheet" href="/pos/static/css/tokens.css" /> <link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" /> <link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" /> <link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" /> <link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" /> <link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" /> <link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" /> <meta name="theme-color" content="#F5A623" />
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" /> <link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
<link rel="stylesheet" href="/pos/static/css/invoicing.css"></head> <link rel="stylesheet" href="/pos/static/css/invoicing.css">
</head>
<body> <body>
@@ -338,14 +339,25 @@
<circle cx="11" cy="11" r="8"/> <circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/> <line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg> </svg>
<input id="facturas-search" type="text" placeholder="Buscar folio, RFC, cliente…" oninput="Invoicing.filterFacturas()" aria-label="Buscar facturas" /> <input type="text" placeholder="Buscar folio, RFC, cliente…" />
</div> </div>
<select id="facturas-status-filter" class="select-filter" aria-label="Filtrar por estatus" onchange="Invoicing.loadFacturas()"> <div class="date-range">
<svg viewBox="0 0 24 24">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
01 Mar 2026 — 31 Mar 2026
</div>
<select class="select-filter" aria-label="Filtrar por estatus">
<option value="">Todas</option> <option value="">Todas</option>
<option value="stamped">Timbradas</option> <option value="timbrada">Timbradas</option>
<option value="pending">Pendientes</option> <option value="pendiente">Pendientes</option>
<option value="cancelled">Canceladas</option> <option value="cancelada">Canceladas</option>
<option value="ppd">PPD</option>
</select> </select>
<div class="toolbar__spacer"></div> <div class="toolbar__spacer"></div>
@@ -360,7 +372,7 @@
Factura Global Factura Global
</button> </button>
<button id="facturas-export-csv" class="btn btn--ghost" onclick="Invoicing.exportFacturasCSV()"> <button class="btn btn--ghost">
<svg viewBox="0 0 24 24"> <svg viewBox="0 0 24 24">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/> <polyline points="7 10 12 15 17 10"/>
@@ -410,10 +422,10 @@
<circle cx="11" cy="11" r="8"/> <circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/> <line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg> </svg>
<input type="text" placeholder="Buscar nota de crédito…" aria-label="Buscar notas de credito" /> <input type="text" placeholder="Buscar nota de crédito…" />
</div> </div>
<div class="toolbar__spacer"></div> <div class="toolbar__spacer"></div>
<button id="notas-export-csv" class="btn btn--ghost" onclick="Invoicing.exportNotasCSV()"> <button class="btn btn--ghost">
<svg viewBox="0 0 24 24"> <svg viewBox="0 0 24 24">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/> <polyline points="7 10 12 15 17 10"/>
@@ -421,7 +433,7 @@
</svg> </svg>
Exportar Exportar
</button> </button>
<button id="notas-new" class="btn btn--primary" onclick="Invoicing.newCreditNote()"> <button class="btn btn--primary">
<svg viewBox="0 0 24 24"> <svg viewBox="0 0 24 24">
<line x1="12" y1="5" x2="12" y2="19"/> <line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/> <line x1="5" y1="12" x2="19" y2="12"/>
@@ -466,9 +478,9 @@
<circle cx="11" cy="11" r="8"/> <circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/> <line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg> </svg>
<input type="text" placeholder="Buscar complemento de pago…" aria-label="Buscar complementos de pago" /> <input type="text" placeholder="Buscar complemento de pago…" />
</div> </div>
<select id="complementos-method-filter" class="select-filter" aria-label="Método de pago" onchange="Invoicing.loadComplementos()"> <select class="select-filter" aria-label="Método de pago">
<option value="">Todos los métodos</option> <option value="">Todos los métodos</option>
<option value="03">03 Transferencia electrónica</option> <option value="03">03 Transferencia electrónica</option>
<option value="04">04 Tarjeta de crédito</option> <option value="04">04 Tarjeta de crédito</option>
@@ -477,7 +489,7 @@
<option value="99">99 Por definir</option> <option value="99">99 Por definir</option>
</select> </select>
<div class="toolbar__spacer"></div> <div class="toolbar__spacer"></div>
<button id="complementos-new" class="btn btn--primary" onclick="Invoicing.newPaymentComplement()"> <button class="btn btn--primary">
<svg viewBox="0 0 24 24"> <svg viewBox="0 0 24 24">
<line x1="12" y1="5" x2="12" y2="19"/> <line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/> <line x1="5" y1="12" x2="19" y2="12"/>
@@ -507,7 +519,17 @@
<div class="table-footer"> <div class="table-footer">
<span></span> <span></span>
<div class="pagination" id="complementos-pagination"></div> <div class="pagination">
<button class="page-btn" aria-label="Anterior">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<button class="page-btn is-active">1</button>
<button class="page-btn">2</button>
<button class="page-btn">3</button>
<button class="page-btn" aria-label="Siguiente">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
</div> </div>
</div> </div>
</div><!-- /panel-complementos --> </div><!-- /panel-complementos -->
@@ -757,47 +779,11 @@
<span class="form-hint">Régimen del emisor según el SAT</span> <span class="form-hint">Régimen del emisor según el SAT</span>
</div> </div>
<div class="form-field form-field--span2">
<label class="form-label" for="direccion-fiscal">Calle</label>
<input class="form-input" id="direccion-fiscal" type="text" value="" placeholder="Ej: Av. Insurgentes Sur" />
</div> </div>
<div class="form-field"> <div style="margin-top:var(--space-4);display:flex;justify-content:flex-end;gap:var(--space-3);">
<label class="form-label" for="numero-exterior">Número Exterior</label> <button class="btn btn--ghost">Cancelar</button>
<input class="form-input" id="numero-exterior" type="text" value="" placeholder="Ej: 123" /> <button class="btn btn--primary">
</div>
<div class="form-field">
<label class="form-label" for="numero-interior">Número Interior</label>
<input class="form-input" id="numero-interior" type="text" value="" placeholder="Ej: 4B" />
</div>
<div class="form-field">
<label class="form-label" for="colonia-fiscal">Colonia</label>
<input class="form-input" id="colonia-fiscal" type="text" value="" />
</div>
<div class="form-field">
<label class="form-label" for="ciudad-fiscal">Ciudad</label>
<input class="form-input" id="ciudad-fiscal" type="text" value="" />
</div>
<div class="form-field">
<label class="form-label" for="municipio-fiscal">Municipio / Alcaldía</label>
<input class="form-input" id="municipio-fiscal" type="text" value="" />
</div>
<div class="form-field">
<label class="form-label" for="estado-fiscal">Estado</label>
<input class="form-input" id="estado-fiscal" type="text" value="" />
</div>
</div>
<div style="margin-top:var(--space-4);display:flex;justify-content:flex-end;gap:var(--space-3);align-items:center;">
<span id="emisor-save-status" style="font-size:var(--text-caption);color:var(--color-text-muted);"></span>
<button class="btn btn--ghost" type="button" onclick="Invoicing.loadEmisorData()">Cancelar</button>
<button class="btn btn--primary" type="button" onclick="Invoicing.saveEmisorData()">
<svg viewBox="0 0 24 24"> <svg viewBox="0 0 24 24">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/> <path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/> <polyline points="17 21 17 13 7 13 7 21"/>
@@ -902,44 +888,6 @@
</div> </div>
</div> </div>
<!-- CARTA MANIFIESTO -->
<div class="config-section" style="grid-column: span 2;">
<div class="config-section__header">
<svg viewBox="0 0 24 24">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
<span class="config-section__title">Carta Manifiesto (SAT)</span>
</div>
<div class="config-section__body" id="manifiesto-panel">
<p style="color:var(--color-text-muted); margin-bottom:var(--space-3);">
Firma la carta manifiesto con tu <strong>FIEL</strong> (e.firma) para autorizar a Facturapi a timbrar CFDI ante el SAT.
Si no la ves, usa el botón para abrirla en una pestaña nueva.
</p>
<div style="border:1px solid var(--color-border); border-radius:var(--radius-md); overflow:hidden; background:var(--color-bg-base);">
<iframe
id="manifiesto-iframe"
src="https://www.facturapi.io/embedded/manifiesto"
title="Firma de Carta Manifiesto"
style="width:100%; height:720px; border:0; display:block;"
loading="lazy"
allow="fullscreen"
></iframe>
</div>
<div style="margin-top:var(--space-3); display:flex; gap:var(--space-3); justify-content:flex-end;">
<a class="btn btn--ghost btn--sm" href="https://www.facturapi.io/manifiesto" target="_blank" rel="noopener">
Abrir portal de firma
</a>
<button type="button" class="btn btn--secondary btn--sm" onclick="Invoicing.reloadManifiesto()">
Recargar firma
</button>
</div>
</div>
</div>
<!-- CONFIGURACIÓN DE SERIES — full width --> <!-- CONFIGURACIÓN DE SERIES — full width -->
<div class="config-section" style="grid-column: span 2;"> <div class="config-section" style="grid-column: span 2;">
<div class="config-section__header"> <div class="config-section__header">
@@ -1137,12 +1085,12 @@
</div> </div>
</div> </div>
<script src="/pos/static/js/i18n.js?v=39" defer></script> <script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/app-init.js?v=8" defer></script> <script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script> <script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script> <script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js?v=46" defer></script> <script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/invoicing.js?v=35" defer></script> <script src="/pos/static/js/invoicing.js?v=3" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script> <script src="/pos/static/js/sync-engine.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script> <script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
<script src="/pos/static/js/pwa-install.js" defer></script> <script src="/pos/static/js/pwa-install.js" defer></script>

View File

@@ -155,7 +155,6 @@
------------------------------------------------------------------ */ ------------------------------------------------------------------ */
const state = { const state = {
selectedUser: null, selectedUser: null,
selectedUserId: null,
pin: [], pin: [],
maxPinLength: 6, maxPinLength: 6,
}; };
@@ -326,21 +325,18 @@
localStorage.setItem('pos_device_id', deviceId); localStorage.setItem('pos_device_id', deviceId);
} }
// Optional auto-redirect disabled to allow switching users on shared devices. // Auto-redirect if already logged in with valid token
// Users with a valid session can navigate directly to /pos/catalog or /pos/workshop.
(function checkExistingSession() { (function checkExistingSession() {
var token = localStorage.getItem('pos_token'); var token = localStorage.getItem('pos_token');
if (token && tenantId) { if (token && tenantId) {
try { try {
var payload = JSON.parse(atob(token.split('.')[1])); var payload = JSON.parse(atob(token.split('.')[1]));
if (payload.exp * 1000 <= Date.now() + 30000) { if (payload.exp * 1000 > Date.now() + 30000) {
localStorage.removeItem('pos_token'); window.location.href = '/pos/catalog';
localStorage.removeItem('pos_employee'); return;
} }
} catch(e) { } catch(e) {}
localStorage.removeItem('pos_token'); localStorage.removeItem('pos_token');
localStorage.removeItem('pos_employee');
}
} }
})(); })();
@@ -355,7 +351,6 @@
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
tenant_id: parseInt(tenantId), tenant_id: parseInt(tenantId),
employee_id: state.selectedUserId,
pin: enteredPin, pin: enteredPin,
device_id: deviceId device_id: deviceId
}) })
@@ -379,19 +374,13 @@
localStorage.setItem('pos_token', result.data.token); localStorage.setItem('pos_token', result.data.token);
localStorage.setItem('pos_employee', JSON.stringify(result.data.employee)); localStorage.setItem('pos_employee', JSON.stringify(result.data.employee));
localStorage.setItem('pos_tenant_id', tenantId); localStorage.setItem('pos_tenant_id', tenantId);
document.cookie = 'pos_role=' + (result.data.employee.role || '') + '; path=/pos; SameSite=Lax';
btnLogin.innerHTML = '<span class="btn-login__icon" aria-hidden="true">✓</span> Bienvenido, ' + result.data.employee.name; btnLogin.innerHTML = '<span class="btn-login__icon" aria-hidden="true">✓</span> Bienvenido, ' + result.data.employee.name;
btnLogin.style.background = 'var(--color-success)'; btnLogin.style.background = 'var(--color-success)';
showToast('¡Acceso concedido! Redirigiendo…'); showToast('¡Acceso concedido! Redirigiendo…');
setTimeout(function() { setTimeout(function() {
var role = (result.data.employee.role || '').toLowerCase();
if (role === 'workshop' || role === 'mechanic' || role === 'counter') {
window.location.href = '/pos/workshop';
} else {
window.location.href = '/pos/catalog'; window.location.href = '/pos/catalog';
}
}, 1000); }, 1000);
}) })
.catch(function() { .catch(function() {
@@ -409,7 +398,6 @@
function resetLoginState() { function resetLoginState() {
state.selectedUser = null; state.selectedUser = null;
state.selectedUserId = null;
state.pin = []; state.pin = [];
userBtns.forEach(b => { userBtns.forEach(b => {
@@ -499,7 +487,6 @@
employees.forEach(function(emp) { employees.forEach(function(emp) {
var btn = document.createElement('button'); var btn = document.createElement('button');
btn.className = 'user-avatar-btn'; btn.className = 'user-avatar-btn';
btn.setAttribute('data-id', emp.id);
btn.setAttribute('data-user', emp.initials); btn.setAttribute('data-user', emp.initials);
btn.setAttribute('data-name', emp.name); btn.setAttribute('data-name', emp.name);
btn.setAttribute('data-role', emp.role_label); btn.setAttribute('data-role', emp.role_label);
@@ -519,7 +506,6 @@
btn.classList.add('selected'); btn.classList.add('selected');
btn.setAttribute('aria-checked', 'true'); btn.setAttribute('aria-checked', 'true');
state.selectedUser = emp.initials; state.selectedUser = emp.initials;
state.selectedUserId = emp.id;
state.pin = []; state.pin = [];
enablePinPad(); enablePinPad();
updatePinDisplay(); updatePinDisplay();

View File

@@ -8,14 +8,15 @@
<link rel="stylesheet" href="/pos/static/css/chat.css" /> <link rel="stylesheet" href="/pos/static/css/chat.css" />
<link rel="stylesheet" href="/pos/static/css/tokens.css" /> <link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" /> <link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" /> <link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" /> <link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" /> <link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" /> <link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" /> <meta name="theme-color" content="#F5A623" />
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" /> <link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
<link rel="stylesheet" href="/pos/static/css/marketplace.css"></head> <link rel="stylesheet" href="/pos/static/css/marketplace.css">
</head>
<body> <body>
<header class="page-header"> <header class="page-header">

View File

@@ -8,7 +8,7 @@
<link rel="stylesheet" href="/pos/static/css/chat.css" /> <link rel="stylesheet" href="/pos/static/css/chat.css" />
<link rel="stylesheet" href="/pos/static/css/tokens.css" /> <link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" /> <link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" /> <link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" /> <link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" /> <link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="stylesheet" href="/pos/static/css/inventory.css" /> <link rel="stylesheet" href="/pos/static/css/inventory.css" />
@@ -28,7 +28,8 @@
.meli-config-row { display:flex;gap:var(--space-4);flex-wrap:wrap;margin-bottom:var(--space-4); } .meli-config-row { display:flex;gap:var(--space-4);flex-wrap:wrap;margin-bottom:var(--space-4); }
.meli-config-row label { display:block;font-size:var(--text-caption);color:var(--color-text-muted);margin-bottom:4px; } .meli-config-row label { display:block;font-size:var(--text-caption);color:var(--color-text-muted);margin-bottom:4px; }
.meli-config-row input, .meli-config-row select { padding:8px 12px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-surface-0);color:var(--color-text-primary); } .meli-config-row input, .meli-config-row select { padding:8px 12px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-surface-0);color:var(--color-text-primary); }
</style></head> </style>
</head>
<body> <body>
@@ -342,12 +343,12 @@
</div> </div>
</div> </div>
<script src="/pos/static/js/i18n.js?v=39" defer></script> <script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/app-init.js?v=8" defer></script> <script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script> <script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script> <script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js?v=46" defer></script> <script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/marketplace_external.js?v=33" defer></script> <script src="/pos/static/js/marketplace_external.js?v=4" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script> <script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
</body> </body>
</html> </html>

View File

@@ -7,7 +7,7 @@
<title>Nexus Autoparts — Punto de Venta</title> <title>Nexus Autoparts — Punto de Venta</title>
<link rel="stylesheet" href="/pos/static/css/tokens.css" /> <link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" /> <link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" /> <link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" /> <link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" /> <link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="stylesheet" href="/pos/static/css/chat.css" /> <link rel="stylesheet" href="/pos/static/css/chat.css" />
@@ -16,7 +16,8 @@
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" /> <link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
<script src="/pos/static/js/native-bridge.js" defer></script> <script src="/pos/static/js/native-bridge.js" defer></script>
<link rel="stylesheet" href="/pos/static/css/pos.css?v=35"></head> <link rel="stylesheet" href="/pos/static/css/pos.css?v=4">
</head>
<body class="pos-shell" id="appBody"> <body class="pos-shell" id="appBody">
@@ -85,7 +86,7 @@
<div class="cart-header"> <div class="cart-header">
<div class="cart-header__top"> <div class="cart-header__top">
<div class="cart-header__sale-id">Venta Activa</div> <div class="cart-header__sale-id">Venta Activa</div>
<button class="cost-toggle" id="costToggle" title="Mostrar costo/margen (Admin)" aria-label="Mostrar costo y margen" style="display:none;">C/M</button> <button class="cost-toggle" id="costToggle" title="Mostrar costo/margen (Admin)" style="display:none;">C/M</button>
<span class="cart-header__status">Activa</span> <span class="cart-header__status">Activa</span>
</div> </div>
@@ -198,31 +199,15 @@
</div> </div>
</div> </div>
<!-- Courier selector for counter remission notes -->
<div class="form-field" id="courierSelectField" style="display:none; margin-bottom: 12px;">
<label class="form-label" for="remissionCourier">Repartidor</label>
<select class="form-input" id="remissionCourier">
<option value="">-- Sin repartidor --</option>
</select>
</div>
<!-- COBRAR Button --> <!-- COBRAR Button -->
<button class="btn-cobrar" id="btnCobrar" onclick="POS.checkout()" aria-label="Procesar cobro"> <button class="btn-cobrar" id="btnCobrar" onclick="POS.checkout()" aria-label="Procesar cobro">
<span>COBRAR</span> <span>COBRAR</span>
</button> </button>
<!-- Counter remission button (shown for counter role when feature enabled) -->
<button class="btn-cobrar" id="btnRemission" onclick="POS.createRemissionNote()" aria-label="Generar nota de remision" style="display:none;background:var(--color-secondary);">
<span>NOTA DE REMISIÓN</span>
</button>
<!-- Secondary Actions --> <!-- Secondary Actions -->
<div class="secondary-actions" role="toolbar" aria-label="Acciones secundarias"> <div class="secondary-actions" role="toolbar" aria-label="Acciones secundarias">
<button class="btn-secondary-action" onclick="POS.modifyPrice()" title="Modificar precio">Mod.Precio</button> <button class="btn-secondary-action" onclick="POS.modifyPrice()" title="Modificar precio">Mod.Precio</button>
<button class="btn-secondary-action" onclick="POS.saveQuotation()" title="Cotizacion (F4)">Cotizar</button> <button class="btn-secondary-action" onclick="POS.saveQuotation()" title="Cotizacion (F4)">Cotizar</button>
<button class="btn-secondary-action" id="btnPayRemission" onclick="POS.openPayRemissionModal()" title="Cobrar nota de remision" style="display:none;">Cobrar Nota</button>
<button class="btn-secondary-action" onclick="POS.createLayaway()" title="Apartado (requiere cliente)">Apartado</button>
<button class="btn-secondary-action" onclick="POS.createServiceOrder()" title="Orden de servicio (F7)">Orden Taller</button>
<button class="btn-secondary-action" onclick="POS.showLastSale()" title="Ultima venta (F5)">Ult.Venta</button> <button class="btn-secondary-action" onclick="POS.showLastSale()" title="Ultima venta (F5)">Ult.Venta</button>
<button class="btn-secondary-action" onclick="POS.showCutZModal()" title="Corte Z - Cerrar caja">Corte Z</button> <button class="btn-secondary-action" onclick="POS.showCutZModal()" title="Corte Z - Cerrar caja">Corte Z</button>
<button class="btn-secondary-action danger" id="btnCancelSale" onclick="POS.openCancelModal()" title="Cancelar (Esc)">Cancelar</button> <button class="btn-secondary-action danger" id="btnCancelSale" onclick="POS.openCancelModal()" title="Cancelar (Esc)">Cancelar</button>
@@ -254,9 +239,6 @@
<div class="fkey" onclick="POS.openDrawer()" title="Abrir cajon"> <div class="fkey" onclick="POS.openDrawer()" title="Abrir cajon">
<span class="fkey-key">F6</span><span class="fkey-label">Cajon</span> <span class="fkey-key">F6</span><span class="fkey-label">Cajon</span>
</div> </div>
<div class="fkey" onclick="POS.createServiceOrder()" title="Orden de servicio">
<span class="fkey-key">F7</span><span class="fkey-label">Orden Taller</span>
</div>
<div class="fkey-sep"></div> <div class="fkey-sep"></div>
<div class="fkey" onclick="POS.changeQuantity()" title="Cantidad +/-"> <div class="fkey" onclick="POS.changeQuantity()" title="Cantidad +/-">
<span class="fkey-key">+/-</span><span class="fkey-label">Cantidad</span> <span class="fkey-key">+/-</span><span class="fkey-label">Cantidad</span>
@@ -308,15 +290,6 @@
<button class="pago-tab" data-method="mixto" onclick="POS.selectPaymentMethod('mixto', this)"> <button class="pago-tab" data-method="mixto" onclick="POS.selectPaymentMethod('mixto', this)">
Mixto Mixto
</button> </button>
<button class="pago-tab" data-method="credito" onclick="POS.selectPaymentMethod('credito', this)">
Crédito
</button>
<button class="pago-tab" data-method="cheque" onclick="POS.selectPaymentMethod('cheque', this)">
Cheque
</button>
<button class="pago-tab" data-method="pendiente" onclick="POS.selectPaymentMethod('pendiente', this)">
Pendiente
</button>
</div> </div>
<!-- TAB: Efectivo --> <!-- TAB: Efectivo -->
@@ -352,26 +325,26 @@
<div class="tab-content" id="mixedPayment"> <div class="tab-content" id="mixedPayment">
<div class="mixed-row" style="margin-bottom:var(--space-3);"> <div class="mixed-row" style="margin-bottom:var(--space-3);">
<div class="form-group"> <div class="form-group">
<label class="form-label" id="label-method-1">Método 1</label> <label class="form-label">Metodo 1</label>
<select id="mixed-method-1" class="form-input" style="margin-bottom:var(--space-2);" aria-label="Método de pago 1"> <select class="form-input" style="margin-bottom:var(--space-2);">
<option value="efectivo">Efectivo</option> <option value="efectivo">Efectivo</option>
<option value="tarjeta">Tarjeta</option> <option value="tarjeta">Tarjeta</option>
<option value="transferencia">Transferencia</option> <option value="transferencia">Transferencia</option>
</select> </select>
<input id="mixed-amount-1" type="number" class="form-input mixed-amount" placeholder="0.00" step="0.01" oninput="POS.updateMixedTotal()" aria-label="Monto método de pago 1" /> <input type="number" class="form-input mixed-amount" placeholder="0.00" step="0.01" oninput="POS.updateMixedTotal()" />
<input id="mixed-ref-1" type="text" class="form-input" placeholder="Referencia (si aplica)" style="margin-top:var(--space-2);" aria-label="Referencia método de pago 1" /> <input type="text" class="form-input" placeholder="Referencia (si aplica)" style="margin-top:var(--space-2);" />
</div> </div>
</div> </div>
<div class="mixed-row" style="margin-bottom:var(--space-3);"> <div class="mixed-row" style="margin-bottom:var(--space-3);">
<div class="form-group"> <div class="form-group">
<label class="form-label" id="label-method-2">Método 2</label> <label class="form-label">Metodo 2</label>
<select id="mixed-method-2" class="form-input" style="margin-bottom:var(--space-2);" aria-label="Método de pago 2"> <select class="form-input" style="margin-bottom:var(--space-2);">
<option value="tarjeta">Tarjeta</option> <option value="tarjeta">Tarjeta</option>
<option value="efectivo">Efectivo</option> <option value="efectivo">Efectivo</option>
<option value="transferencia">Transferencia</option> <option value="transferencia">Transferencia</option>
</select> </select>
<input id="mixed-amount-2" type="number" class="form-input mixed-amount" placeholder="0.00" step="0.01" oninput="POS.updateMixedTotal()" aria-label="Monto método de pago 2" /> <input type="number" class="form-input mixed-amount" placeholder="0.00" step="0.01" oninput="POS.updateMixedTotal()" />
<input id="mixed-ref-2" type="text" class="form-input" placeholder="Referencia (si aplica)" style="margin-top:var(--space-2);" aria-label="Referencia método de pago 2" /> <input type="text" class="form-input" placeholder="Referencia (si aplica)" style="margin-top:var(--space-2);" />
</div> </div>
</div> </div>
<div class="split-remaining"> <div class="split-remaining">
@@ -380,35 +353,6 @@
</div> </div>
</div> </div>
<!-- TAB: Crédito -->
<div class="tab-content" id="creditPayment">
<div class="form-group">
<label class="form-label">Venta a crédito</label>
<p style="color:var(--color-text-muted);font-size:var(--text-body-sm);">Se registrará como venta a crédito para el cliente seleccionado y se agregará a su cuenta.</p>
</div>
</div>
<!-- TAB: Cheque -->
<div class="tab-content" id="chequePayment">
<div class="form-group">
<label class="form-label">Monto</label>
<input type="text" class="form-input form-input-lg" id="chequeAmount" readonly />
</div>
<div class="form-group">
<label class="form-label">No. de cheque / referencia</label>
<input type="text" class="form-input" id="chequeRef" placeholder="No. de cheque" />
</div>
<div class="form-hint">Verificar que el cheque sea válido antes de confirmar</div>
</div>
<!-- TAB: Pendiente -->
<div class="tab-content" id="pendingPayment">
<div class="form-group">
<label class="form-label">Pago pendiente</label>
<p style="color:var(--color-text-muted);font-size:var(--text-body-sm);">La venta quedará pendiente de pago. Podrá cobrarse más tarde desde el listado de ventas o notas de remisión.</p>
</div>
</div>
<!-- CFDI Checkbox --> <!-- CFDI Checkbox -->
<div class="cfdi-check"> <div class="cfdi-check">
<input type="checkbox" id="cfdiCheck" /> <input type="checkbox" id="cfdiCheck" />
@@ -424,68 +368,6 @@
</div> </div>
</div> </div>
<!-- ================================================================
PAY REMISSION NOTE MODAL
================================================================ -->
<div class="modal-overlay" id="payRemissionModal">
<div class="modal-pago">
<div class="modal-header">
<h3>Cobrar Nota de Remisión</h3>
<button class="modal-close" onclick="POS.closePayRemissionModal()">&#x2715;</button>
</div>
<div class="form-row" style="padding: 16px; gap: 8px;">
<input class="form-input" type="number" id="payRemissionFolio" placeholder="Folio de la nota (NR-XXXX)" style="flex:1;" />
<button class="btn btn-primary" onclick="POS.searchRemissionToPay()">Buscar</button>
</div>
<div id="payRemissionDetail" style="padding: 0 16px 16px; max-height: 220px; overflow-y: auto;"></div>
<div id="payRemissionActions" style="display:none; padding: 0 16px 16px;">
<div class="form-row" style="gap: 8px; margin-bottom: 8px;">
<select class="form-input" id="payRemissionMethod" onchange="POS.updatePayRemissionMethod()">
<option value="efectivo">Efectivo</option>
<option value="transferencia">Transferencia</option>
<option value="tarjeta">Tarjeta</option>
<option value="mixto">Mixto</option>
</select>
</div>
<div id="payRemissionCash" class="form-row" style="gap: 8px; margin-bottom: 8px;">
<input class="form-input" type="number" id="payRemissionReceived" placeholder="Recibido" step="0.01" />
</div>
<div id="payRemissionRef" class="form-row" style="gap: 8px; margin-bottom: 8px; display:none;">
<input class="form-input" type="text" id="payRemissionReference" placeholder="Referencia" />
</div>
<div id="payRemissionMixed" class="form-row" style="gap: 8px; margin-bottom: 8px; display:none; flex-direction: column;">
<div class="mixed-row" style="display:flex; gap:8px; width:100%;">
<select class="form-input" style="flex:1;">
<option value="efectivo">Efectivo</option>
<option value="transferencia">Transferencia</option>
<option value="tarjeta">Tarjeta</option>
</select>
<input class="form-input mixed-amount" type="number" placeholder="Monto" step="0.01" style="flex:1;" />
<input class="form-input" type="text" placeholder="Referencia" style="flex:1;" />
</div>
<div class="mixed-row" style="display:flex; gap:8px; width:100%;">
<select class="form-input" style="flex:1;">
<option value="efectivo">Efectivo</option>
<option value="transferencia">Transferencia</option>
<option value="tarjeta">Tarjeta</option>
</select>
<input class="form-input mixed-amount" type="number" placeholder="Monto" step="0.01" style="flex:1;" />
<input class="form-input" type="text" placeholder="Referencia" style="flex:1;" />
</div>
</div>
<div class="modal-footer" style="padding:0;">
<button class="btn btn-ghost" onclick="POS.closePayRemissionModal()">Cancelar</button>
<button class="btn btn-primary" id="btnConfirmPayRemission" onclick="POS.confirmPayRemission()">Confirmar Pago</button>
</div>
</div>
<div id="payRemissionResult" style="padding: 0 16px 16px;"></div>
</div>
</div>
<!-- ================================================================ <!-- ================================================================
CANCEL SALE CONFIRMATION MODAL CANCEL SALE CONFIRMATION MODAL
================================================================ --> ================================================================ -->
@@ -602,10 +484,10 @@
<div style="margin-top:var(--space-4);border-top:1px solid var(--color-border);padding-top:var(--space-4);"> <div style="margin-top:var(--space-4);border-top:1px solid var(--color-border);padding-top:var(--space-4);">
<div class="form-label" style="margin-bottom:var(--space-3);">Vehiculo (opcional)</div> <div class="form-label" style="margin-bottom:var(--space-3);">Vehiculo (opcional)</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);">
<div class="form-group"><label class="form-label">Marca</label><input type="text" class="form-input" id="ncVehMake" placeholder="Marca" aria-label="Marca del vehiculo" /></div> <div class="form-group"><input type="text" class="form-input" id="ncVehMake" placeholder="Marca" /></div>
<div class="form-group"><label class="form-label">Modelo</label><input type="text" class="form-input" id="ncVehModel" placeholder="Modelo" aria-label="Modelo del vehiculo" /></div> <div class="form-group"><input type="text" class="form-input" id="ncVehModel" placeholder="Modelo" /></div>
<div class="form-group"><label class="form-label">Año</label><input type="text" class="form-input" id="ncVehYear" placeholder="Ano" aria-label="Ano del vehiculo" /></div> <div class="form-group"><input type="text" class="form-input" id="ncVehYear" placeholder="Ano" /></div>
<div class="form-group"><label class="form-label">Placas</label><input type="text" class="form-input" id="ncVehPlates" placeholder="Placas" aria-label="Placas del vehiculo" /></div> <div class="form-group"><input type="text" class="form-input" id="ncVehPlates" placeholder="Placas" /></div>
</div> </div>
</div> </div>
</div> </div>
@@ -679,61 +561,17 @@
================================================================ --> ================================================================ -->
<div class="toast-container" id="toastContainer" aria-live="assertive" aria-atomic="true"></div> <div class="toast-container" id="toastContainer" aria-live="assertive" aria-atomic="true"></div>
<!-- ================================================================
SERVICE ORDER MODAL
================================================================ -->
<div class="modal-overlay" id="serviceOrderModal">
<div class="modal-pago" style="width:480px;">
<div class="modal-header">
<h3>Orden de servicio desde POS</h3>
<button class="modal-close" onclick="POS.closeServiceOrderModal()">&#x2715;</button>
</div>
<div style="padding:var(--space-6);">
<div class="form-group">
<label class="form-label">Cliente</label>
<input type="text" class="form-input" id="soCustomer" readonly />
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);">
<div class="form-group">
<label class="form-label">Veh&iacute;culo</label>
<input type="text" class="form-input" id="soVehicle" placeholder="Marca / Modelo / Placas" />
</div>
<div class="form-group">
<label class="form-label">V&iacute;a de entrega</label>
<select class="form-input" id="soDelivery">
<option value=""></option>
<option value="pickup">Pasa cliente</option>
<option value="delivery">Env&iacute;o a domicilio</option>
<option value="courier">Motociclista</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">Notas de recepci&oacute;n</label>
<textarea class="form-input" id="soNotes" rows="3" placeholder="Falla reportada, observaciones..."></textarea>
</div>
<label style="display:flex;align-items:center;gap:var(--space-2);color:var(--color-text-primary);font-size:var(--text-body-sm);">
<input type="checkbox" id="soDirect" /> Orden directa
</label>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="POS.closeServiceOrderModal()">Cancelar</button>
<button class="btn btn-primary" onclick="POS.confirmServiceOrder()">Crear orden e imprimir</button>
</div>
</div>
</div>
<!-- ================================================================ <!-- ================================================================
JAVASCRIPT JAVASCRIPT
================================================================ --> ================================================================ -->
<script src="/pos/static/js/i18n.js?v=39" defer></script> <script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/kiosk.js" defer></script> <script src="/pos/static/js/kiosk.js" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script> <script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/app-init.js?v=8" defer></script> <script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script> <script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/push.js" defer></script> <script src="/pos/static/js/push.js" defer></script>
<script src="/pos/static/js/printer.js" defer></script> <script src="/pos/static/js/printer.js" defer></script>
<script src="/pos/static/js/pos.js?v=42" defer></script> <script src="/pos/static/js/pos.js?v=7" defer></script>
<script> <script>
// Cancel sale button wiring // Cancel sale button wiring

View File

@@ -8,16 +8,17 @@
<link rel="stylesheet" href="/pos/static/css/chat.css" /> <link rel="stylesheet" href="/pos/static/css/chat.css" />
<link rel="stylesheet" href="/pos/static/css/tokens.css" /> <link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" /> <link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" /> <link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" /> <link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" /> <link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="stylesheet" href="/pos/static/css/quotations.css"></head> <link rel="stylesheet" href="/pos/static/css/quotations.css">
</head>
<body> <body>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script> <script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/i18n.js?v=39" defer></script> <script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/app-init.js?v=8" defer></script> <script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script> <script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/sidebar.js?v=46" defer></script> <script src="/pos/static/js/sidebar.js" defer></script>
<div class="page"> <div class="page">
<h1 class="page-title">Cotizaciones</h1> <h1 class="page-title">Cotizaciones</h1>

View File

@@ -1,430 +0,0 @@
<!DOCTYPE html>
<html lang="es">
<head>
<script>/*pos_theme_early*/(function(){var t=localStorage.getItem("pos_theme")||"industrial";document.documentElement.setAttribute("data-theme",t);})()</script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Notas de Remisión — Nexus Autoparts POS</title>
<link rel="stylesheet" href="/pos/static/css/chat.css" />
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="stylesheet" href="/pos/static/css/inventory.css?v=34" />
<link rel="stylesheet" href="/pos/static/css/remission_notes.css?v=1" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" />
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
</head>
<body>
<!-- THEME BAR -->
<header class="theme-bar" role="banner">
<div class="theme-bar__left">
<div class="theme-bar__store">
<span class="theme-bar__dot"></span>
Nexus Autoparts
</div>
<div class="theme-bar__sep"></div>
<span class="theme-bar__label">Sucursal Centro &mdash; Usuario: H. García</span>
</div>
<div class="theme-bar__right">
<span class="theme-bar__label">Tema:</span>
<button class="theme-btn theme-btn--industrial is-active" data-theme-target="industrial" onclick="setTheme('industrial')">
<span class="theme-btn__swatch"></span>
Industrial
</button>
<button class="theme-btn theme-btn--modern" data-theme-target="modern" onclick="setTheme('modern')">
<span class="theme-btn__swatch"></span>
Moderno
</button>
</div>
</header>
<!-- APP SHELL -->
<div class="app-shell">
<!-- SIDEBAR -->
<aside class="sidebar" role="navigation" aria-label="Navegación principal">
<div class="sidebar__brand">
<div class="brand-logo">NA</div>
<div class="brand-name">
<span class="brand-name__primary">Nexus</span>
<span class="brand-name__sub">Autoparts POS</span>
</div>
</div>
<nav class="sidebar__nav">
<div class="nav-section-label">Principal</div>
<a class="nav-item" href="/pos/dashboard">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>
</svg>
<span>Dashboard</span>
</a>
<a class="nav-item" href="/pos/sale">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>
</svg>
<span>POS</span>
</a>
<a class="nav-item" href="/pos/catalog">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
</svg>
<span>Catálogo</span>
</a>
<a class="nav-item" href="/pos/inventory">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>
</svg>
<span>Inventario</span>
</a>
<div class="nav-section-label">Gestión</div>
<a class="nav-item" href="/pos/customers">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
<span>Clientes</span>
</a>
<a class="nav-item is-active" href="/pos/remission-notes" aria-current="page">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
<span>Notas de Remisión</span>
</a>
<a class="nav-item" href="/pos/invoicing">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
</svg>
<span>Facturación</span>
</a>
<a class="nav-item" href="/pos/reports">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/>
<line x1="6" y1="20" x2="6" y2="14"/>
</svg>
<span>Reportes</span>
</a>
<div class="nav-section-label">Sistema</div>
<a class="nav-item" href="/pos/config">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>
</svg>
<span>Configuración</span>
</a>
</nav>
<div class="sidebar__footer">
<div class="sidebar__user-avatar">HG</div>
<div class="sidebar__user-info">
<div class="sidebar__user-name">Hugo García</div>
<div class="sidebar__user-role">Administrador</div>
</div>
</div>
</aside>
<!-- MAIN CONTENT -->
<main class="main" role="main">
<div class="page-header">
<div class="page-header__title-group">
<span class="page-header__eyebrow">Ventas</span>
<h1 class="page-header__title">Notas de Remisión</h1>
</div>
</div>
<div class="page-content">
<!-- Filters -->
<div class="filters-card">
<div class="toolbar">
<div class="search-box">
<svg viewBox="0 0 24 24" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="filterFolio" placeholder="Buscar folio NR-XXXX..." />
</div>
<div class="search-box">
<svg viewBox="0 0 24 24" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="filterCustomer" placeholder="Cliente..." />
</div>
<select class="select-filter" id="filterStatus">
<option value="">Todos los estados</option>
<option value="pending_payment" selected>Pendientes</option>
<option value="completed">Pagadas</option>
<option value="cancelled">Canceladas</option>
</select>
<select class="select-filter" id="filterCourier">
<option value="">Todos los repartidores</option>
</select>
<input type="date" class="select-filter" id="dateFrom" />
<input type="date" class="select-filter" id="dateTo" />
<div class="toolbar__spacer"></div>
<button class="btn btn--primary" onclick="loadData(1)">
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
Buscar
</button>
</div>
</div>
<!-- Table -->
<div class="table-wrapper">
<table class="data-table" id="remissionTable">
<thead>
<tr>
<th>Folio</th>
<th>Fecha</th>
<th>Cliente</th>
<th>Vendedor</th>
<th>Repartidor</th>
<th style="text-align:right">Total</th>
<th>Estado</th>
<th style="text-align:right">Acciones</th>
</tr>
</thead>
<tbody id="remissionTableBody">
<tr><td colspan="8" style="text-align:center;padding:var(--space-8);">Cargando...</td></tr>
</tbody>
</table>
<div class="table-footer">
<div class="pagination" id="pagination"></div>
</div>
</div>
</div>
</main>
</div>
<!-- Ticket Modal -->
<div class="modal-overlay" id="ticketModal" onclick="if(event.target===this) closeTicketModal()">
<div class="modal">
<div class="modal__header">
<h3 class="modal__title">Vista previa</h3>
<button class="modal__close" onclick="closeTicketModal()">&times;</button>
</div>
<div class="modal__body">
<div id="ticketContent" class="ticket-preview"></div>
</div>
<div class="modal__footer">
<button class="btn btn--ghost" onclick="closeTicketModal()">Cerrar</button>
<button class="btn btn--primary" onclick="printTicket()">
<svg viewBox="0 0 24 24"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
Imprimir
</button>
</div>
</div>
</div>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/app-init.js?v=8" defer></script>
<script src="/pos/static/js/sidebar.js?v=46" defer></script>
<script>
const token = localStorage.getItem('pos_token') || '';
let currentPage = 1;
let couriers = [];
function headers() {
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
}
async function api(url, options = {}) {
options.headers = headers();
const res = await fetch(url, options);
const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
return data;
}
const fmt = (n) => '$' + parseFloat(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
function statusLabel(status) {
return { pending_payment: 'Pendiente', completed: 'Pagada', cancelled: 'Cancelada' }[status] || status;
}
function statusClass(status) {
return { pending_payment: 'badge--pending_payment', completed: 'badge--completed', cancelled: 'badge--cancelled' }[status] || '';
}
async function loadCouriers() {
try {
const data = await api('/pos/api/logistics/couriers');
couriers = data.couriers || [];
const sel = document.getElementById('filterCourier');
const current = sel.value;
sel.innerHTML = '<option value="">Todos los repartidores</option>' + couriers.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
sel.value = current;
} catch (e) {
console.warn('No se pudieron cargar repartidores', e);
}
}
async function loadData(page) {
currentPage = page;
const tbody = document.getElementById('remissionTableBody');
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;padding:var(--space-8);color:var(--color-text-muted);">Cargando...</td></tr>';
const params = new URLSearchParams();
params.set('sale_type', 'counter_remission');
params.set('per_page', '50');
params.set('page', String(page));
const status = document.getElementById('filterStatus').value;
if (status) params.set('status', status);
const folio = document.getElementById('filterFolio').value.trim();
if (folio) params.set('q', folio.replace(/^NR-/i, ''));
const customer = document.getElementById('filterCustomer').value.trim();
if (customer) params.set('customer', customer);
const courier = document.getElementById('filterCourier').value;
if (courier) params.set('courier_id', courier);
const from = document.getElementById('dateFrom').value;
const to = document.getElementById('dateTo').value;
if (from) params.set('date_from', from);
if (to) params.set('date_to', to);
try {
const res = await api('/pos/api/sales?' + params.toString());
render(res.data || [], res.pagination || {});
} catch (e) {
tbody.innerHTML = `<tr><td colspan="8" style="text-align:center;padding:var(--space-8);color:var(--color-error);">Error: ${e.message}</td></tr>`;
}
}
function render(rows, pagination) {
const tbody = document.getElementById('remissionTableBody');
const pag = document.getElementById('pagination');
if (!rows.length) {
tbody.innerHTML = `<tr><td colspan="8">
<div class="empty-state">
<div class="empty-state__title">No hay notas de remisión</div>
<div class="empty-state__subtitle">Ajusta los filtros o genera una nueva nota desde el POS.</div>
</div>
</td></tr>`;
pag.innerHTML = '';
return;
}
tbody.innerHTML = rows.map(r => `
<tr>
<td class="td--mono">NR-${r.id}</td>
<td>${new Date(r.created_at).toLocaleString('es-MX')}</td>
<td class="td--primary">${r.customer_name || 'Público General'}</td>
<td>${r.employee_name || '-'}</td>
<td>${r.courier_name || '-'}</td>
<td class="td--amount" style="text-align:right">${fmt(r.total)}</td>
<td><span class="badge ${statusClass(r.status)}">${statusLabel(r.status)}</span></td>
<td style="text-align:right">
<div style="display:flex;justify-content:flex-end;gap:var(--space-2);">
<button class="action-btn action-btn--ghost" onclick="viewTicket(${r.id})" title="Imprimir">
<svg viewBox="0 0 24 24"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
</button>
</div>
</td>
</tr>
`).join('');
if (pagination.total_pages > 1) {
pag.innerHTML = `
<button class="page-btn" onclick="loadData(${pagination.page - 1})" ${pagination.page <= 1 ? 'disabled' : ''}>&larr;</button>
<span style="color:var(--color-text-muted);font-size:var(--text-caption);">Página ${pagination.page} de ${pagination.total_pages}</span>
<button class="page-btn" onclick="loadData(${pagination.page + 1})" ${pagination.page >= pagination.total_pages ? 'disabled' : ''}>&rarr;</button>
`;
} else {
pag.innerHTML = '';
}
}
async function viewTicket(saleId) {
try {
const data = await api('/pos/api/sales/' + saleId + '/print-remission', { method: 'POST', body: '{}' });
const dateStr = new Date(data.date).toLocaleString('es-MX');
const itemsHtml = (data.items || []).map(it => `
<div class="ticket-preview__item"><span>${it.quantity} x ${it.name}</span><span>${fmt(it.subtotal)}</span></div>
`).join('');
document.getElementById('ticketContent').innerHTML = `
<div class="ticket-preview__center ticket-preview__bold">${data.business_name || 'NEXUS AUTOPARTS'}</div>
<div class="ticket-preview__center">${data.business_rfc || ''}</div>
<div class="ticket-preview__center">${data.business_address || ''}</div>
<div class="ticket-preview__divider"></div>
<div class="ticket-preview__center ticket-preview__bold">NOTA DE REMISIÓN</div>
<div class="ticket-preview__center">${data.folio}</div>
<div class="ticket-preview__center">${dateStr}</div>
<div class="ticket-preview__divider"></div>
<div class="ticket-preview__line"><span>Cliente:</span><span>${data.customer || 'Público General'}</span></div>
<div class="ticket-preview__line"><span>Vendedor:</span><span>${data.employee || '-'}</span></div>
${data.courier ? `<div class="ticket-preview__line"><span>Repartidor:</span><span>${data.courier}</span></div>` : ''}
<div class="ticket-preview__items">${itemsHtml}</div>
<div class="ticket-preview__divider"></div>
<div class="ticket-preview__line"><span>Subtotal:</span><span>${fmt(data.subtotal)}</span></div>
${data.discount_total ? `<div class="ticket-preview__line"><span>Descuento:</span><span>-${fmt(data.discount_total)}</span></div>` : ''}
<div class="ticket-preview__line"><span>IVA:</span><span>${fmt(data.tax_total)}</span></div>
<div class="ticket-preview__line ticket-preview__bold"><span>TOTAL:</span><span>${fmt(data.total)}</span></div>
<div class="ticket-preview__divider"></div>
<div class="ticket-preview__footer ticket-preview__bold">PENDIENTE DE PAGO</div>
<div class="ticket-preview__footer" style="font-size:0.75rem;opacity:0.8;">Presente esta nota en caja para pagar</div>
`;
document.getElementById('ticketModal').classList.add('is-open');
} catch (e) {
alert('Error: ' + e.message);
}
}
function closeTicketModal() {
document.getElementById('ticketModal').classList.remove('is-open');
}
function printTicket() {
const w = window.open('', '_blank');
w.document.write('<html><head><title>Nota de Remisión</title></head><body>' + document.getElementById('ticketContent').innerHTML + '</body></html>');
w.document.close();
w.print();
}
async function payNote(saleId, total) {
const method = prompt(`Cobrar nota NR-${saleId}\nTotal: ${fmt(total)}\n\nForma de pago: efectivo / transferencia / tarjeta`, 'efectivo');
if (!method) return;
const reference = ['transferencia', 'tarjeta'].includes(method) ? prompt('Referencia:') : '';
try {
await api('/pos/api/sales/' + saleId + '/pay', {
method: 'POST',
body: JSON.stringify({
payment_method: method,
amount_paid: total,
reference: reference || ''
})
});
alert('Nota cobrada correctamente');
loadData(currentPage);
} catch (e) {
alert('Error al cobrar: ' + e.message);
}
}
(async function init() {
if (!token) {
document.getElementById('remissionTableBody').innerHTML = `<tr><td colspan="8">
<div class="empty-state">
<div class="empty-state__title">Inicia sesión</div>
<div class="empty-state__subtitle">Se requiere autenticación para ver las notas de remisión.</div>
</div>
</td></tr>`;
return;
}
await loadCouriers();
loadData(1);
})();
</script>
</body>
</html>

View File

@@ -8,14 +8,15 @@
<link rel="stylesheet" href="/pos/static/css/chat.css" /> <link rel="stylesheet" href="/pos/static/css/chat.css" />
<link rel="stylesheet" href="/pos/static/css/tokens.css" /> <link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" /> <link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" /> <link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" /> <link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" /> <link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" /> <link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" /> <meta name="theme-color" content="#F5A623" />
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" /> <link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
<link rel="stylesheet" href="/pos/static/css/reports.css"></head> <link rel="stylesheet" href="/pos/static/css/reports.css">
</head>
<body> <body>
@@ -229,14 +230,6 @@
</svg> </svg>
Histórico Histórico
</button> </button>
<button class="tab-btn" onclick="switchTab('cortes', this)">
<svg viewBox="0 0 15 15" fill="none" stroke="currentColor" stroke-width="1.4">
<rect x="1" y="3" width="13" height="10" rx="1"/>
<path d="M4 7h7M4 10h5"/>
<circle cx="11" cy="10" r="1.5" fill="currentColor"/>
</svg>
Cortes de caja
</button>
</div> </div>
<!-- ================================================================== <!-- ==================================================================
@@ -363,38 +356,6 @@
<!-- Sales detail table --> <!-- Sales detail table -->
<div class="table-card mb-5" id="historico-detalle"></div> <div class="table-card mb-5" id="historico-detalle"></div>
</div>
<!-- ==================================================================
TAB 6: CORTES DE CAJA
================================================================== -->
<div class="tab-panel" id="panel-cortes">
<!-- Filter Bar -->
<div class="filter-bar">
<span class="filter-bar__label">Desde</span>
<input type="date" class="filter-input" id="cortes-date-from" />
<span class="filter-bar__label">Hasta</span>
<input type="date" class="filter-input" id="cortes-date-to" />
<span id="cortes-employee-filter">
<span class="filter-bar__label">Cajero</span>
<select class="filter-select" id="cortes-employee">
<option value="">Todos</option>
</select>
</span>
<div class="filter-bar__spacer"></div>
<button class="btn btn-primary btn-sm" onclick="Reports.loadCortes()">Generar</button>
</div>
<!-- KPI Cards (dynamic) -->
<div class="kpi-grid" id="cortes-kpis"></div>
<!-- Cortes detail table -->
<div class="table-card mb-5" id="cortes-detalle"></div>
<!-- Detail of sales for the selected cash cut -->
<div class="table-card mb-5" id="corte-ventas-detalle" style="display:none;"></div>
</div> </div>
<!-- End panels --> <!-- End panels -->
@@ -405,12 +366,12 @@
</div> </div>
<!-- End app-shell --> <!-- End app-shell -->
<script src="/pos/static/js/i18n.js?v=39" defer></script> <script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/app-init.js?v=8" defer></script> <script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script> <script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script> <script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js?v=46" defer></script> <script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/reports.js?v=35" defer></script> <script src="/pos/static/js/reports.js?v=3" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script> <script src="/pos/static/js/sync-engine.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script> <script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
<script src="/pos/static/js/pwa-install.js" defer></script> <script src="/pos/static/js/pwa-install.js" defer></script>

View File

@@ -7,7 +7,7 @@
<title>Catalogo de Proveedores — Nexus Autoparts POS</title> <title>Catalogo de Proveedores — Nexus Autoparts POS</title>
<link rel="stylesheet" href="/pos/static/css/tokens.css" /> <link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" /> <link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" /> <link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" /> <link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" /> <link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" /> <link rel="manifest" href="/pos/static/pwa/manifest.json" />
@@ -49,7 +49,8 @@
.sc-interchange-list { display:flex; flex-wrap:wrap; gap:var(--space-2); } .sc-interchange-list { display:flex; flex-wrap:wrap; gap:var(--space-2); }
.sc-interchange-chip { background:var(--color-surface-2); border:1px solid var(--color-border); border-radius:var(--radius-full); padding:2px 10px; font-size:var(--text-caption); } .sc-interchange-chip { background:var(--color-surface-2); border:1px solid var(--color-border); border-radius:var(--radius-full); padding:2px 10px; font-size:var(--text-caption); }
.sc-close { background:none; border:none; font-size:20px; color:var(--color-text-muted); cursor:pointer; } .sc-close { background:none; border:none; font-size:20px; color:var(--color-text-muted); cursor:pointer; }
</style></head> </style>
</head>
<body> <body>
<!-- Theme bar --> <!-- Theme bar -->
@@ -126,9 +127,9 @@
</div> </div>
</div> </div>
<script src="/pos/static/js/app-init.js?v=8" defer></script> <script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/sidebar.js?v=46" defer></script> <script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/supplier_catalog.js?v=33" defer></script> <script src="/pos/static/js/supplier_catalog.js?v=2" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script> <script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
</body> </body>
</html> </html>

View File

@@ -11,14 +11,15 @@
<link rel="stylesheet" href="/pos/static/css/chat.css" /> <link rel="stylesheet" href="/pos/static/css/chat.css" />
<link rel="stylesheet" href="/pos/static/css/tokens.css" /> <link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" /> <link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" /> <link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" /> <link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" /> <link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" /> <link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" /> <meta name="theme-color" content="#F5A623" />
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" /> <link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
<link rel="stylesheet" href="/pos/static/css/whatsapp.css"></head> <link rel="stylesheet" href="/pos/static/css/whatsapp.css">
</head>
<body> <body>
<div class="page-shell"> <div class="page-shell">
@@ -131,12 +132,12 @@ function posLogout(){localStorage.removeItem('pos_token');window.location.href='
</script> </script>
<!-- Sidebar --> <!-- Sidebar -->
<script src="/pos/static/js/i18n.js?v=39" defer></script> <script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/app-init.js?v=8" defer></script> <script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script> <script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/whatsapp2.js?v=33" defer></script> <script src="/pos/static/js/whatsapp2.js?v=5" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script> <script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js?v=46" defer></script> <script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/chat.js" defer></script> <script src="/pos/static/js/chat.js" defer></script>
</body> </body>

View File

@@ -8,19 +8,14 @@
<link rel="stylesheet" href="/pos/static/css/chat.css" /> <link rel="stylesheet" href="/pos/static/css/chat.css" />
<link rel="stylesheet" href="/pos/static/css/tokens.css" /> <link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" /> <link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" /> <link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" /> <link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" /> <link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" /> <link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" /> <meta name="theme-color" content="#F5A623" />
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" /> <link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
<link rel="stylesheet" href="/pos/static/css/workshop.css?v=44"> <link rel="stylesheet" href="/pos/static/css/workshop.css?v=2">
<style>
.so-notes-grid { display: grid; grid-template-columns: 120px 1fr; gap: var(--space-2); align-items: start; }
.so-notes-grid .form-label { margin: 0; padding-top: var(--space-2); }
.so-search-result:hover { background: var(--color-bg-secondary); }
</style>
</head> </head>
<body> <body>
@@ -33,13 +28,13 @@
<div class="page-header"> <div class="page-header">
<div class="page-header__title-group"> <div class="page-header__title-group">
<span class="page-header__eyebrow">Operaci&oacute;n &middot; Taller</span> <span class="page-header__eyebrow">Operación · Taller</span>
<h1 class="page-header__title">Taller</h1> <h1 class="page-header__title">Taller</h1>
</div> </div>
<div class="page-header__actions"> <div class="page-header__actions">
<button class="btn btn--ghost" id="btnCatalog" onclick="Workshop.openCatalogModal()"> <button class="btn btn--ghost" id="btnCatalog" onclick="Workshop.openCatalogModal()">
<svg viewBox="0 0 24 24"><path d="M4 6h16M4 10h16M4 14h16M4 18h16"/></svg> <svg viewBox="0 0 24 24"><path d="M4 6h16M4 10h16M4 14h16M4 18h16"/></svg>
Cat&aacute;logo de servicios Catálogo de servicios
</button> </button>
<button class="btn btn--primary" id="btnNewOrder" onclick="Workshop.openNewOrderModal()"> <button class="btn btn--primary" id="btnNewOrder" onclick="Workshop.openNewOrderModal()">
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> <svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
@@ -55,7 +50,7 @@
<svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10z"/><polyline points="3 8 12 13 21 8"/></svg> <svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10z"/><polyline points="3 8 12 13 21 8"/></svg>
</div> </div>
<div class="summary-card__body"> <div class="summary-card__body">
<div class="summary-card__label">Por revisar</div> <div class="summary-card__label">Recibidos</div>
<div class="summary-card__value" id="statReceived">--</div> <div class="summary-card__value" id="statReceived">--</div>
</div> </div>
</div> </div>
@@ -64,7 +59,7 @@
<svg viewBox="0 0 24 24"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg> <svg viewBox="0 0 24 24"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
</div> </div>
<div class="summary-card__body"> <div class="summary-card__body">
<div class="summary-card__label">En reparaci&oacute;n</div> <div class="summary-card__label">En reparación</div>
<div class="summary-card__value" id="statRepair">--</div> <div class="summary-card__value" id="statRepair">--</div>
</div> </div>
</div> </div>
@@ -73,7 +68,7 @@
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="9 12 12 15 16 10"/></svg> <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="9 12 12 15 16 10"/></svg>
</div> </div>
<div class="summary-card__body"> <div class="summary-card__body">
<div class="summary-card__label">Por entregar</div> <div class="summary-card__label">Listos</div>
<div class="summary-card__value" id="statReady">--</div> <div class="summary-card__value" id="statReady">--</div>
</div> </div>
</div> </div>
@@ -88,81 +83,8 @@
</div> </div>
</div> </div>
<!-- Toolbar: filters + view switch -->
<div class="workshop-toolbar">
<div class="workshop-filters">
<select class="form-input" id="filterBranch">
<option value="">Todas las sucursales</option>
</select>
<select class="form-input" id="filterStatus">
<option value="">Todos los estatus</option>
<option value="por_revisar">Por revisar</option>
<option value="en_revision">En revisi&oacute;n</option>
<option value="revisada">Revisada</option>
<option value="cotizada">Cotizada</option>
<option value="por_autorizar">Por autorizar</option>
<option value="autorizada">Autorizada</option>
<option value="autorizacion_parcial">Autorizaci&oacute;n parcial</option>
<option value="en_reparacion">En reparaci&oacute;n</option>
<option value="reparada">Reparada</option>
<option value="por_entregar">Por entregar</option>
<option value="entregado">Entregado</option>
<option value="por_enviar">Por enviar</option>
<option value="enviado">Enviado</option>
<option value="por_facturar">Por facturar</option>
<option value="facturada">Facturada</option>
<option value="por_recolectar">Por recolectar</option>
<option value="cancelada">Cancelada</option>
</select>
<select class="form-input" id="filterDelivery">
<option value="">Todas las v&iacute;as de entrega</option>
<option value="pickup">Mostrador</option>
<option value="delivery">Env&iacute;o a domicilio</option>
</select>
<label class="toolbar-toggle">
<input type="checkbox" id="filterDirect" />
<span>Orden directa</span>
</label>
<input class="form-input" id="filterSearch" placeholder="Buscar orden, cliente o placas" />
</div>
<div class="view-switch" id="viewSwitch">
<button class="view-switch__btn is-active" data-view="list" onclick="Workshop.setView('list')">
<svg viewBox="0 0 24 24"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
Lista
</button>
<button class="view-switch__btn" data-view="kanban" onclick="Workshop.setView('kanban')">
<svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
Kanban
</button>
</div>
</div>
<!-- List view -->
<div class="workshop-list" id="listView">
<div class="table-wrapper">
<table class="data-table workshop-table">
<thead>
<tr>
<th>Sucursal</th>
<th>Orden</th>
<th class="restricted-hide">Cliente</th>
<th class="restricted-hide">Taller</th>
<th class="restricted-hide">Veh&iacute;culo</th>
<th>Estatus</th>
<th class="price-col restricted-hide">Total</th>
<th>Acciones</th>
</tr>
</thead>
<tbody id="listBody">
<tr><td colspan="8" style="text-align:center;padding:var(--space-4);">Cargando...</td></tr>
</tbody>
</table>
</div>
<div class="list-pagination" id="listPagination"></div>
</div>
<!-- Kanban board --> <!-- Kanban board -->
<div class="kanban-board" id="kanbanBoard" style="display:none;"> <div class="kanban-board" id="kanbanBoard">
<!-- Columns injected by JS --> <!-- Columns injected by JS -->
</div> </div>
</main> </main>
@@ -193,72 +115,36 @@
</div> </div>
<div class="modal__body"> <div class="modal__body">
<form id="newOrderForm" class="form-grid"> <form id="newOrderForm" class="form-grid">
<div class="form-field">
<label class="form-label" for="noBranch">Sucursal</label>
<select class="form-input" id="noBranch" required></select>
</div>
<div class="form-field"> <div class="form-field">
<label class="form-label" for="noCustomer">Cliente</label> <label class="form-label" for="noCustomer">Cliente</label>
<div style="display:flex;gap:var(--space-2);"> <select class="form-input" id="noCustomer" required></select>
<select class="form-input" id="noCustomer" required style="flex:1;"></select>
<button class="btn btn--secondary" type="button" onclick="Workshop.openNewCustomerModalFromNewOrder()">+ Nuevo</button>
</div>
</div> </div>
<div class="form-field"> <div class="form-field">
<label class="form-label" for="noWorkshopName">Taller</label> <label class="form-label" for="noVehicle">Vehículo</label>
<input class="form-input" id="noWorkshopName" placeholder="Nombre del taller" /> <select class="form-input" id="noVehicle"></select>
</div> </div>
<div class="form-field"> <div class="form-field">
<label class="form-label" for="noCustomerPhone">Tel&eacute;fono</label> <label class="form-label" for="noMechanic">Mecánico asignado</label>
<input class="form-input" id="noCustomerPhone" placeholder="3312345678" />
</div>
<div class="form-field form-field--span2">
<label class="form-label" for="noCustomerAddress">Direcci&oacute;n</label>
<input class="form-input" id="noCustomerAddress" placeholder="Calle, n&uacute;mero, colonia" />
</div>
<div class="form-field">
<label class="form-label" for="noVehicleDescription">Veh&iacute;culo</label>
<input class="form-input" id="noVehicleDescription" placeholder="Ej. Versa 2020" />
</div>
<div class="form-field">
<label class="form-label" for="noDelivery">V&iacute;a de entrega</label>
<select class="form-input" id="noDelivery">
<option value=""></option>
<option value="pickup">Mostrador</option>
<option value="delivery">Env&iacute;o a domicilio</option>
<option value="courier">Motociclista</option>
</select>
</div>
<div class="form-field" id="courierField" style="display:none;">
<label class="form-label" for="noCourier">Motociclista</label>
<select class="form-input" id="noCourier"></select>
</div>
<div class="form-field">
<label class="form-label" for="noMechanic">Mec&aacute;nico asignado (usuario)</label>
<select class="form-input" id="noMechanic"></select> <select class="form-input" id="noMechanic"></select>
</div> </div>
<div class="form-field"> <div class="form-field">
<label class="form-label" for="noMechanicName">Mec&aacute;nico asignado (nombre libre)</label> <label class="form-label" for="noPriority">Prioridad</label>
<input class="form-input" type="text" id="noMechanicName" placeholder="Ej. Juan Pérez" /> <select class="form-input" id="noPriority">
<option value="normal">Normal</option>
<option value="high">Alta</option>
<option value="urgent">Urgente</option>
</select>
</div> </div>
<div class="form-field"> <div class="form-field">
<label class="form-label" for="noEstimatedCost">Presupuesto</label> <label class="form-label" for="noEstimatedCompletion">Entrega estimada</label>
<input class="form-input" type="number" id="noEstimatedCost" step="0.01" placeholder="0.00" /> <input class="form-input" type="datetime-local" id="noEstimatedCompletion" />
</div> </div>
<div class="form-field"> <div class="form-field">
<label class="toolbar-toggle"> <label class="form-label" for="noMileage">Kilometraje</label>
<input type="checkbox" id="noRequiresInvoice" /> <input class="form-input" type="number" id="noMileage" placeholder="Ej. 45200" />
<span>Requiere factura</span>
</label>
</div>
<div class="form-field">
<label class="toolbar-toggle">
<input type="checkbox" id="noDirect" />
<span>Orden directa</span>
</label>
</div> </div>
<div class="form-field form-field--span2"> <div class="form-field form-field--span2">
<label class="form-label" for="noNotes">Observaciones</label> <label class="form-label" for="noNotes">Notas de recepción</label>
<textarea class="form-input" id="noNotes" rows="3" placeholder="Falla reportada, observaciones..."></textarea> <textarea class="form-input" id="noNotes" rows="3" placeholder="Falla reportada, observaciones..."></textarea>
</div> </div>
</form> </form>
@@ -270,184 +156,11 @@
</div> </div>
</div> </div>
<!-- Edit order modal -->
<div class="modal-overlay" id="editOrderModal">
<div class="modal modal--md">
<div class="modal__header">
<h2 class="modal__title">Editar orden</h2>
<button class="modal__close" onclick="Workshop.closeEditOrderModal()">&times;</button>
</div>
<div class="modal__body">
<form class="form-grid" id="editOrderForm" onsubmit="return false;">
<div class="form-field form-field--span2" style="position:relative;">
<label class="form-label" for="eoCustomerSearch">Cliente</label>
<div style="display:flex;gap:var(--space-2);">
<div style="position:relative;flex:1;">
<input class="form-input" id="eoCustomerSearch" autocomplete="off" placeholder="Buscar cliente..." oninput="Workshop.searchCustomersForSO()" />
<div id="eoCustomerResults" style="display:none;position:absolute;z-index:10;top:100%;left:0;right:0;max-height:180px;overflow-y:auto;background:#fff;border:1px solid var(--color-border);border-radius:var(--radius-md);box-shadow:0 4px 12px rgba(0,0,0,.15);"></div>
</div>
<button class="btn btn--secondary" type="button" onclick="Workshop.openNewCustomerModal('edit')">+ Nuevo</button>
</div>
<input type="hidden" id="eoCustomerId" />
</div>
<div class="form-field">
<label class="form-label" for="eoBranch">Sucursal</label>
<select class="form-input" id="eoBranch"></select>
</div>
<div class="form-field">
<label class="form-label" for="eoWorkshopName">Taller</label>
<input class="form-input" id="eoWorkshopName" placeholder="Nombre del taller" />
</div>
<div class="form-field">
<label class="form-label" for="eoCustomerPhone">Tel&eacute;fono</label>
<input class="form-input" id="eoCustomerPhone" placeholder="3312345678" />
</div>
<div class="form-field form-field--span2">
<label class="form-label" for="eoCustomerAddress">Direcci&oacute;n</label>
<input class="form-input" id="eoCustomerAddress" placeholder="Calle, n&uacute;mero, colonia" />
</div>
<div class="form-field">
<label class="form-label" for="eoVehicleDescription">Veh&iacute;culo</label>
<input class="form-input" id="eoVehicleDescription" placeholder="Ej. Versa 2020" />
</div>
<div class="form-field">
<label class="form-label" for="eoDelivery">V&iacute;a de entrega</label>
<select class="form-input" id="eoDelivery">
<option value=""></option>
<option value="pickup">Mostrador</option>
<option value="delivery">Env&iacute;o a domicilio</option>
<option value="courier">Motociclista</option>
</select>
</div>
<div class="form-field" id="eoCourierField" style="display:none;">
<label class="form-label" for="eoCourier">Motociclista</label>
<select class="form-input" id="eoCourier"></select>
</div>
<div class="form-field">
<label class="form-label" for="eoEstimatedCost">Presupuesto</label>
<input class="form-input" type="number" id="eoEstimatedCost" step="0.01" />
</div>
<div class="form-field">
<label class="form-label" for="eoMechanic">Mec&aacute;nico asignado (usuario)</label>
<select class="form-input" id="eoMechanic"></select>
</div>
<div class="form-field">
<label class="form-label" for="eoMechanicName">Mec&aacute;nico asignado (nombre libre)</label>
<input class="form-input" type="text" id="eoMechanicName" placeholder="Ej. Juan Pérez" />
</div>
<div class="form-field">
<label class="toolbar-toggle">
<input type="checkbox" id="eoRequiresInvoice" />
<span>Requiere factura</span>
</label>
</div>
<div class="form-field form-field--span2">
<label class="form-label" for="eoNotes">Observaciones</label>
<textarea class="form-input" id="eoNotes" rows="3"></textarea>
</div>
</form>
</div>
<div class="modal__footer">
<button class="btn btn--ghost" onclick="Workshop.closeEditOrderModal()">Cancelar</button>
<button class="btn btn--primary" onclick="Workshop.saveOrderChanges()">Guardar cambios</button>
</div>
</div>
</div>
<!-- New vehicle modal -->
<div class="modal-overlay" id="newVehicleModal" style="z-index:9001;">
<div class="modal modal--sm">
<div class="modal__header">
<h2 class="modal__title">Nuevo veh&iacute;culo</h2>
<button class="modal__close" onclick="Workshop.closeNewVehicleModal()">&times;</button>
</div>
<div class="modal__body">
<form class="form-grid" id="newVehicleForm" onsubmit="return false;">
<div class="form-field">
<label class="form-label" for="nvPlate">Placa</label>
<input class="form-input" id="nvPlate" placeholder="Ej. ABC-123 (opcional)" />
</div>
<div class="form-field">
<label class="form-label" for="nvMake">Marca *</label>
<input class="form-input" id="nvMake" placeholder="Ej. Nissan" />
</div>
<div class="form-field">
<label class="form-label" for="nvModel">Modelo *</label>
<input class="form-input" id="nvModel" placeholder="Ej. Versa" />
</div>
<div class="form-field">
<label class="form-label" for="nvYear">A&ntilde;o</label>
<input class="form-input" id="nvYear" type="number" placeholder="2020" />
</div>
<div class="form-field">
<label class="form-label" for="nvColor">Color</label>
<input class="form-input" id="nvColor" placeholder="Ej. Blanco" />
</div>
<div class="form-field form-field--span2">
<label class="form-label" for="nvCustomerName">Cliente *</label>
<input type="hidden" id="nvCustomerId" />
<input class="form-input" id="nvCustomerName" readonly placeholder="Selecciona un cliente en la orden" />
</div>
</form>
</div>
<div class="modal__footer">
<button class="btn btn--ghost" onclick="Workshop.closeNewVehicleModal()">Cancelar</button>
<button class="btn btn--primary" onclick="Workshop.saveNewVehicle()">Guardar veh&iacute;culo</button>
</div>
</div>
</div>
<!-- New customer modal -->
<div class="modal-overlay" id="newCustomerModal" style="z-index:9001;">
<div class="modal modal--sm">
<div class="modal__header">
<h2 class="modal__title">Nuevo cliente</h2>
<button class="modal__close" onclick="Workshop.closeNewCustomerModal()">&times;</button>
</div>
<div class="modal__body">
<form class="form-grid" id="newCustomerForm" onsubmit="return false;">
<div class="form-field form-field--span2">
<label class="form-label" for="ncName">Nombre *</label>
<input class="form-input" id="ncName" placeholder="Nombre completo" />
</div>
<div class="form-field">
<label class="form-label" for="ncPhone">Tel&eacute;fono</label>
<input class="form-input" id="ncPhone" placeholder="3312345678" />
</div>
<div class="form-field">
<label class="form-label" for="ncEmail">Correo</label>
<input class="form-input" id="ncEmail" placeholder="cliente@ejemplo.com" />
</div>
<div class="form-field">
<label class="form-label" for="ncRfc">RFC</label>
<input class="form-input" id="ncRfc" placeholder="XAXX010101000" />
</div>
<div class="form-field">
<label class="form-label" for="ncPriceTier">Lista de precios</label>
<select class="form-input" id="ncPriceTier">
<option value="1">Mostrador</option>
<option value="2">Taller</option>
<option value="3">Mayoreo</option>
</select>
</div>
<div class="form-field form-field--span2">
<label class="form-label" for="ncAddress">Direcci&oacute;n</label>
<input class="form-input" id="ncAddress" placeholder="Calle, n&uacute;mero, colonia" />
</div>
</form>
</div>
<div class="modal__footer">
<button class="btn btn--ghost" onclick="Workshop.closeNewCustomerModal()">Cancelar</button>
<button class="btn btn--primary" onclick="Workshop.saveNewCustomer()">Guardar cliente</button>
</div>
</div>
</div>
<!-- Catalog modal --> <!-- Catalog modal -->
<div class="modal-overlay" id="catalogModal"> <div class="modal-overlay" id="catalogModal">
<div class="modal modal--lg"> <div class="modal modal--lg">
<div class="modal__header"> <div class="modal__header">
<h2 class="modal__title">Cat&aacute;logo de servicios</h2> <h2 class="modal__title">Catálogo de servicios</h2>
<button class="modal__close" onclick="Workshop.closeCatalogModal()">&times;</button> <button class="modal__close" onclick="Workshop.closeCatalogModal()">&times;</button>
</div> </div>
<div class="modal__body"> <div class="modal__body">
@@ -458,11 +171,11 @@
<div class="form-field"> <div class="form-field">
<input class="form-input" id="catHours" type="number" step="0.1" placeholder="Horas" /> <input class="form-input" id="catHours" type="number" step="0.1" placeholder="Horas" />
</div> </div>
<div class="form-field price-col"> <div class="form-field">
<input class="form-input" id="catRate" type="number" step="0.01" placeholder="Precio/hora" /> <input class="form-input" id="catRate" type="number" step="0.01" placeholder="Precio/hora" />
</div> </div>
<div class="form-field form-field--span3"> <div class="form-field form-field--span3">
<input class="form-input" id="catDesc" placeholder="Descripci&oacute;n" /> <input class="form-input" id="catDesc" placeholder="Descripción" />
</div> </div>
<div class="form-field"> <div class="form-field">
<button class="btn btn--primary" onclick="Workshop.addCatalogItem()">Agregar</button> <button class="btn btn--primary" onclick="Workshop.addCatalogItem()">Agregar</button>
@@ -475,8 +188,8 @@
<tr> <tr>
<th>Servicio</th> <th>Servicio</th>
<th>Horas</th> <th>Horas</th>
<th class="price-col">Precio/hora</th> <th>Precio/hora</th>
<th class="price-col">Total sugerido</th> <th>Total sugerido</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -487,19 +200,16 @@
</div> </div>
</div> </div>
</div> </div>
<div class="modal__footer">
<button class="btn btn--ghost" onclick="Workshop.closeCatalogModal()">Cerrar</button>
</div>
</div> </div>
</div> </div>
<script src="/pos/static/js/i18n.js?v=39" defer></script> <script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/app-init.js?v=8" defer></script> <script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script> <script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script> <script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js?v=46" defer></script> <script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/offline-banner.js" defer></script> <script src="/pos/static/js/offline-banner.js" defer></script>
<script src="/pos/static/js/workshop.js?v=57" defer></script> <script src="/pos/static/js/workshop.js?v=2" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script> <script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
<script src="/pos/static/js/pwa-install.js" defer></script> <script src="/pos/static/js/pwa-install.js" defer></script>
<script src="/pos/static/js/chat.js" defer></script> <script src="/pos/static/js/chat.js" defer></script>

View File

@@ -64,20 +64,17 @@ def conn():
return MockConn(MockCursor()) return MockConn(MockCursor())
def test_generate_order_number_first_of_day(conn): def test_generate_order_number_first_of_year(conn):
conn._cursor.responses = [(None,)] conn._cursor.responses = [(None,)]
number = engine._generate_order_number(conn) number = engine._generate_order_number(conn)
# Format DDMMYYYY-N assert number.startswith("SO-")
assert len(number.split("-")) == 2 assert number.endswith("-0001")
assert number.split("-")[1] == "1"
def test_generate_order_number_increments(conn): def test_generate_order_number_increments(conn):
from datetime import datetime conn._cursor.responses = [("SO-2026-0042",)]
today = datetime.utcnow().strftime('%d%m%Y')
conn._cursor.responses = [(f"{today}-42",)]
number = engine._generate_order_number(conn) number = engine._generate_order_number(conn)
assert number == f"{today}-43" assert number.endswith("-0043")
@mock.patch("services.inventory_engine.get_stock", return_value=10) @mock.patch("services.inventory_engine.get_stock", return_value=10)
@@ -85,11 +82,11 @@ def test_generate_order_number_increments(conn):
def test_reserve_item_inserts_so_reserve_and_updates_quantity(mock_record, mock_stock, conn): def test_reserve_item_inserts_so_reserve_and_updates_quantity(mock_record, mock_stock, conn):
conn._cursor.responses = [ conn._cursor.responses = [
(1, 5, 3, "pending", "SO-2026-0001", 2), # item lookup (branch_id=2) (1, 5, 3, "pending", "SO-2026-0001"), # item lookup
None, # update None, # update
] ]
result = engine.reserve_item(conn, 7, branch_id=99, employee_id=9) result = engine.reserve_item(conn, 7, branch_id=2, employee_id=9)
assert result["reserved"] == 3 assert result["reserved"] == 3
mock_stock.assert_called_once_with(conn, 5, 2) mock_stock.assert_called_once_with(conn, 5, 2)
@@ -102,11 +99,11 @@ def test_reserve_item_inserts_so_reserve_and_updates_quantity(mock_record, mock_
@mock.patch("services.inventory_engine.get_stock", return_value=1) @mock.patch("services.inventory_engine.get_stock", return_value=1)
def test_reserve_item_raises_when_insufficient_stock(mock_stock, conn): def test_reserve_item_raises_when_insufficient_stock(mock_stock, conn):
conn._cursor.responses = [ conn._cursor.responses = [
(1, 5, 3, "pending", "SO-2026-0001", 2), (1, 5, 3, "pending", "SO-2026-0001"),
] ]
with pytest.raises(ValueError, match="Insufficient stock"): with pytest.raises(ValueError, match="Insufficient stock"):
engine.reserve_item(conn, 7, branch_id=99) engine.reserve_item(conn, 7, branch_id=2)
@mock.patch("services.inventory_engine.record_operation", return_value=124) @mock.patch("services.inventory_engine.record_operation", return_value=124)

View File

@@ -8,7 +8,6 @@ flask-sqlalchemy>=3.1
PyJWT>=2.8 PyJWT>=2.8
bcrypt>=4.0 bcrypt>=4.0
openpyxl>=3.1 openpyxl>=3.1
fpdf2>=2.8
orjson orjson
quart quart
asyncpg asyncpg

View File

@@ -1,70 +0,0 @@
#!/usr/bin/env python3
"""
One-off helper: allocate existing customer_payments (abonos) to the oldest
unpaid credit sales for each customer.
This fixes historical data recorded before the customer-payment endpoint
started creating sale_payments rows automatically.
Run against a tenant database, e.g.:
DATABASE_URL=postgresql://postgres@localhost/tenant_refaccionaria_la_casita \
python3 scripts/allocate_existing_customer_payments.py
"""
import os
import sys
import psycopg2
DB_URL = os.environ.get('DATABASE_URL')
if not DB_URL:
print('DATABASE_URL is required')
sys.exit(1)
conn = psycopg2.connect(DB_URL)
cur = conn.cursor()
cur.execute("""
SELECT customer_id, SUM(amount) as total_paid
FROM customer_payments
GROUP BY customer_id
ORDER BY customer_id
""")
customer_payments = cur.fetchall()
inserted = 0
for customer_id, total_paid in customer_payments:
total_paid = float(total_paid or 0)
if total_paid <= 0:
continue
cur.execute("""
SELECT s.id,
s.total - COALESCE(SUM(sp.amount), 0) as balance
FROM sales s
LEFT JOIN sale_payments sp ON sp.sale_id = s.id
WHERE s.customer_id = %s
AND s.sale_type = 'credit'
AND s.status = 'completed'
GROUP BY s.id, s.total, s.created_at
HAVING s.total - COALESCE(SUM(sp.amount), 0) > 0
ORDER BY s.created_at
""", (customer_id,))
remaining = round(total_paid, 2)
for sale_id, balance in cur.fetchall():
if remaining <= 0:
break
pay = round(min(remaining, float(balance)), 2)
cur.execute("""
INSERT INTO sale_payments (sale_id, method, amount, reference)
VALUES (%s, 'efectivo', %s, %s)
""", (sale_id, pay, f'Abono cliente #{customer_id} (reproceso)'))
remaining = round(remaining - pay, 2)
inserted += 1
if remaining > 0:
print(f' Warning: customer {customer_id} has ${remaining} not allocated to any sale')
conn.commit()
cur.close()
conn.close()
print(f'Allocated existing customer payments into {inserted} sale_payments rows.')

View File

@@ -1,79 +0,0 @@
const { chromium } = require('playwright');
const fs = require('fs');
const path = require('path');
const http = require('http');
function apiLogin() {
return new Promise((resolve, reject) => {
const payload = JSON.stringify({
grant_type: 'password',
client_secret: 'JiRE9iL3pqRnqcFp6wDeYH0tYu97QSpkrwVKAvEP',
client_id: 2,
username: 'IVAN@flechasyventiladores-rached.com',
password: 'Nexus01'
});
const req = http.request({
hostname: 'appapi.flechasyventiladores-rached.com',
path: '/api/seguridad/login',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Origin': 'http://app.flechasyventiladores-rached.com',
'Referer': 'http://app.flechasyventiladores-rached.com/',
'Content-Length': Buffer.byteLength(payload)
}
}, res => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try { resolve(JSON.parse(data)); } catch (e) { reject(e); }
});
});
req.on('error', reject);
req.write(payload);
req.end();
});
}
(async () => {
const outDir = path.resolve(__dirname, '../data/rached_import/har');
fs.mkdirSync(outDir, { recursive: true });
const loginResp = await apiLogin();
const sesion = JSON.stringify(loginResp.datos);
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
const allRequests = [];
await page.route('**/*', route => {
const req = route.request();
allRequests.push({ method: req.method(), url: req.url(), headers: req.headers(), postData: req.postData() });
route.continue();
});
await page.goto('http://app.flechasyventiladores-rached.com/', { waitUntil: 'networkidle' });
await page.waitForTimeout(2000);
await page.evaluate(s => { localStorage.setItem('sesion', s); }, sesion);
const routes = [
'/administracion/servicios/ordenesservicio',
'/administracion/catalogos/clientes',
'/administracion/catalogos/articulos',
'/administracion/catalogos/mecanicos',
'/administracion/catalogos/sucursales',
'/administracion/catalogos/motociclistas',
'/administracion/catalogos/viasentrega',
'/administracion/catalogos/telefonostipos',
'/administracion/catalogos/direccionestipos',
];
for (const r of routes) {
await page.goto('http://app.flechasyventiladores-rached.com' + r, { waitUntil: 'networkidle' });
await page.waitForTimeout(5000);
}
fs.writeFileSync(path.join(outDir, 'all_requests.json'), JSON.stringify(allRequests, null, 2));
await page.screenshot({ path: path.join(outDir, 'last_screen.png'), fullPage: true });
await browser.close();
console.log('Captured', allRequests.length, 'total requests');
})();

View File

@@ -1,142 +0,0 @@
#!/usr/bin/env python3
"""Clone inventory tables from one tenant DB to another.
Uses COPY with the columns common to both source and target, so minor schema
mismatches (e.g. missing latitude/longitude in branches) are handled.
Example:
python scripts/clone_inventory.py \
--source tenant_autopartes_estrada \
--target tenant_originales_autopartes
"""
import argparse
import sys
import tempfile
import time
from contextlib import closing
import psycopg2
# (table, source_where_clause)
TABLES = [
("branches", "id <> 1"),
("inventory", None),
("inventory_stock", None),
("inventory_sku_aliases", None),
("inventory_stock_summary", None),
("inventory_vehicle_compat", None),
]
SEQUENCES = [
("branches_id_seq", "branches"),
("inventory_id_seq", "inventory"),
("inventory_stock_id_seq", "inventory_stock"),
("inventory_sku_aliases_id_seq", "inventory_sku_aliases"),
("inventory_vehicle_compat_id_seq", "inventory_vehicle_compat"),
]
def connect(db_name: str):
return psycopg2.connect(host="localhost", user="postgres", dbname=db_name)
def get_columns(cur, table: str):
cur.execute(
"""
SELECT column_name
FROM information_schema.columns
WHERE table_name = %s
AND table_schema = 'public'
ORDER BY ordinal_position
""",
(table,),
)
return [r[0] for r in cur.fetchall()]
def copy_table(src_conn, dst_conn, table: str, where: str | None):
with src_conn.cursor() as src_cur, dst_conn.cursor() as dst_cur:
src_cols = get_columns(src_cur, table)
dst_cols = set(get_columns(dst_cur, table))
common = [c for c in src_cols if c in dst_cols]
if not common:
print(f"Skipping {table}: no common columns")
return
col_sql = ", ".join(f'"{c}"' for c in common)
copy_to = f'COPY (SELECT {col_sql} FROM "{table}"'
if where:
copy_to += f" WHERE {where}"
copy_to += ") TO STDOUT"
copy_from = f'COPY "{table}" ({col_sql}) FROM STDIN'
print(f"Copying {table} ({len(common)} columns)...", end=" ", flush=True)
start = time.time()
with tempfile.SpooledTemporaryFile(max_size=50 * 1024 * 1024, mode="w+b") as tmp:
src_cur.copy_expert(copy_to, tmp)
tmp.seek(0)
dst_cur.copy_expert(copy_from, tmp)
dst_conn.commit()
elapsed = time.time() - start
print(f"done in {elapsed:.1f}s")
def reset_sequences(dst_conn):
with dst_conn.cursor() as cur:
for seq, table in SEQUENCES:
cur.execute(
f"SELECT setval('{seq}', COALESCE((SELECT MAX(id) FROM \"{table}\"), 1), true)"
)
dst_conn.commit()
def main():
parser = argparse.ArgumentParser(description="Clone inventory between tenant DBs")
parser.add_argument("--source", required=True)
parser.add_argument("--target", required=True)
args = parser.parse_args()
src = connect(args.source)
dst = connect(args.target)
try:
# Prepare target: remove extra branches, truncate inventory tables.
with dst.cursor() as cur:
print("Preparing target tables...", end=" ", flush=True)
cur.execute("DELETE FROM branches WHERE id <> 1")
cur.execute(
"""
TRUNCATE TABLE inventory,
inventory_stock,
inventory_sku_aliases,
inventory_stock_summary,
inventory_vehicle_compat
CASCADE
"""
)
dst.commit()
print("done")
for table, where in TABLES:
copy_table(src, dst, table, where)
print("Resetting sequences...", end=" ", flush=True)
reset_sequences(dst)
print("done")
print("Inventory clone completed.")
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
dst.rollback()
raise
finally:
src.close()
dst.close()
if __name__ == "__main__":
main()

View File

@@ -1,97 +0,0 @@
#!/usr/bin/env python3
"""
Importa solo el stock positivo del respaldo Punto Zero (datos1.productos.Existencia)
al tenant de La Casita. Usa part_number como llave de cruce.
Requiere pymysql y psycopg2. Instalar con:
pip3 install --target /tmp/pylibs pymysql psycopg2-binary
"""
import os
import sys
sys.path.insert(0, "/tmp/pylibs")
import pymysql
import psycopg2
MYSQL_HOST = os.getenv("MYSQL_HOST", "127.0.0.1")
MYSQL_PORT = int(os.getenv("MYSQL_PORT", "3307"))
MYSQL_DB = os.getenv("MYSQL_DB", "datos1")
MYSQL_USER = os.getenv("MYSQL_USER", "root")
MYSQL_PASS = os.getenv("MYSQL_PASS", "")
PG_URL = os.getenv(
"TENANT_DB_URL",
"postgresql://postgres@localhost/tenant_refaccionaria_la_casita",
)
BRANCH_ID = int(os.getenv("BRANCH_ID", "1"))
def main():
mysql = pymysql.connect(
host=MYSQL_HOST,
port=MYSQL_PORT,
user=MYSQL_USER,
password=MYSQL_PASS,
db=MYSQL_DB,
charset="latin1",
)
pg = psycopg2.connect(PG_URL)
mycur = mysql.cursor()
mycur.execute("SELECT Clave, Existencia FROM productos WHERE Existencia > 0")
rows = mycur.fetchall()
pgcur = pg.cursor()
inserted = 0
updated = 0
skipped = 0
for clave, existencia in rows:
sku = str(clave).strip() if clave else ""
if not sku:
skipped += 1
continue
stock = int(round(float(existencia)))
if stock <= 0:
continue
# Buscar inventory_id por part_number
pgcur.execute(
"SELECT id FROM inventory WHERE part_number = %s LIMIT 1",
(sku,),
)
inv_row = pgcur.fetchone()
if not inv_row:
skipped += 1
continue
inventory_id = inv_row[0]
pgcur.execute(
"""
INSERT INTO inventory_stock (inventory_id, branch_id, stock, location)
VALUES (%s, %s, %s, NULL)
ON CONFLICT (inventory_id, branch_id) DO UPDATE
SET stock = EXCLUDED.stock,
updated_at = NOW()
""",
(inventory_id, BRANCH_ID, stock),
)
if pgcur.rowcount == 1:
# psycopg2 rowcount for INSERT ... ON CONFLICT is tricky
inserted += 1
else:
updated += 1
pg.commit()
pgcur.close()
mycur.close()
mysql.close()
pg.close()
print(f"Productos con stock en respaldo: {len(rows)}")
print(f"Filas insertadas/actualizadas en inventory_stock: {inserted + updated}")
print(f"Sin coincidencia de part_number o SKU vacío: {skipped}")
if __name__ == "__main__":
main()

View File

@@ -1,442 +0,0 @@
#!/usr/bin/env python3
"""Import Rached legacy workshop data into the Nexus tenant_refaccionaria_rached DB."""
import json
import os
import re
import sys
from datetime import datetime
from pathlib import Path
import psycopg2
BASE_DIR = Path(__file__).resolve().parent.parent
DATA_DIR = BASE_DIR / 'data' / 'rached_import'
TENANT_DB = 'tenant_refaccionaria_rached'
# Load DB URL from environment or use localhost defaults
DB_URL = os.environ.get(
'TENANT_DB_URL',
f'postgresql://postgres@localhost/{TENANT_DB}'
)
def normalize_name(s):
if not s:
return ''
return re.sub(r'[^a-z0-9]', '', s.lower().replace('sucursal', '').strip())
def load_json(name):
with open(DATA_DIR / f'{name}.json', encoding='utf-8') as f:
return json.load(f)
def load_detail_json(cid):
path = DATA_DIR / 'client_details' / f'client_{cid}.json'
if path.exists():
with open(path, encoding='utf-8') as f:
return json.load(f)
return None
def connect():
return psycopg2.connect(DB_URL)
ORDER_STATUS_MAP = {
1: 'por_recolectar',
2: 'por_revisar',
3: 'en_revision',
4: 'revisada',
5: 'cotizada',
6: 'por_autorizar',
7: 'autorizada',
8: 'autorizacion_parcial',
9: 'cancelada',
10: 'en_reparacion',
11: 'reparada',
12: 'por_entregar',
13: 'enviado',
14: 'entregado',
15: 'por_facturar',
16: 'facturada',
17: 'por_enviar',
}
ITEM_STATUS_MAP = {
1: 'por_revisar',
2: 'revisando',
3: 'revisado',
4: 'cotizado',
5: 'por_autorizar',
6: 'autorizado',
7: 'cancelado',
8: 'en_reparacion',
9: 'reparado',
10: 'por_entregar',
11: 'enviado',
12: 'entregado',
13: 'por_enviar',
}
def get_default_branch(cur):
cur.execute("SELECT id FROM branches WHERE is_main = true LIMIT 1")
row = cur.fetchone()
if row:
return row[0]
cur.execute("SELECT id FROM branches ORDER BY id LIMIT 1")
return cur.fetchone()[0]
def ensure_branches(cur):
cur.execute("SELECT id, name FROM branches")
existing = {normalize_name(name): id for id, name in cur.fetchall()}
catalog = load_json('catalog_sucursales')['datos']
mapping = {}
for s in catalog:
name = (s.get('nombre') or '').strip()
# Skip the erroneous Rached branch
if name.upper() == 'CREMALLERAS':
continue
key = normalize_name(name)
if key in existing:
mapping[s['id']] = existing[key]
else:
cur.execute(
"INSERT INTO branches (name, is_active) VALUES (%s, true) RETURNING id",
(s['nombre'],)
)
new_id = cur.fetchone()[0]
existing[key] = new_id
mapping[s['id']] = new_id
print(f"Created branch {s['nombre']} -> {new_id}")
return mapping
def ensure_customers(cur, branch_map):
# collect unique client ids referenced in orders
client_ids = set()
for f in DATA_DIR.glob('details/order_*.json'):
d = json.loads(f.read_text(encoding='utf-8')).get('datos', {})
cid = d.get('idCliente')
if cid:
client_ids.add(cid)
# load existing customers by name
cur.execute("SELECT id, name FROM customers")
existing_by_name = {name.strip().lower(): id for id, name in cur.fetchall()}
client_map = {}
default_branch = get_default_branch(cur)
for cid in sorted(client_ids):
detail = load_detail_json(cid)
datos = detail.get('datos', {}) if detail else {}
# Name: prefer 'taller' if meaningful, else full name
taller = (datos.get('taller') or '').strip()
nombre = (datos.get('nombre') or '').strip()
ap1 = (datos.get('primerApellido') or '').strip()
ap2 = (datos.get('segundoApellido') or '').strip()
if taller:
name = taller
else:
name = ' '.join([nombre, ap1, ap2]).strip()
if not name:
name = f"Cliente Rached {cid}"
if name.lower() in existing_by_name:
client_map[cid] = existing_by_name[name.lower()]
continue
# phone / address
phone = None
for t in datos.get('telefonos', []):
num = t.get('numero')
if num:
phone = str(num)
break
address = None
for drec in datos.get('direcciones', []):
address = drec.get('completa')
if address:
break
cur.execute(
"""
INSERT INTO customers (branch_id, name, phone, address, price_tier, is_active)
VALUES (%s, %s, %s, %s, 2, true) RETURNING id
""",
(default_branch, name, phone, address)
)
new_id = cur.fetchone()[0]
existing_by_name[name.lower()] = new_id
client_map[cid] = new_id
print(f"Created customer {cid}: {name}")
return client_map
def ensure_employees(cur, branch_map, role, catalog, existing_names=None):
default_branch = get_default_branch(cur)
emp_map = {}
if existing_names is None:
cur.execute("SELECT id, name FROM employees")
existing_names = {name.strip().lower(): id for id, name in cur.fetchall()}
for e in catalog:
name = (e.get('nombre') or '').strip()
if not name:
continue
key = name.lower()
if key in existing_names:
emp_map[e['id']] = existing_names[key]
continue
is_active = bool(e.get('activo', True))
cur.execute(
"""
INSERT INTO employees (name, role, branch_id, is_active)
VALUES (%s, %s, %s, %s) RETURNING id
""",
(name, role, default_branch, is_active)
)
new_id = cur.fetchone()[0]
existing_names[key] = new_id
emp_map[e['id']] = new_id
print(f"Created employee {role} {e['id']}: {name}")
return emp_map
def ensure_couriers(cur, catalog, tenant_id=31):
cur.execute("SELECT id, name FROM couriers WHERE tenant_id = %s", (tenant_id,))
existing = {name.strip().lower(): id for id, name in cur.fetchall()}
courier_map = {}
for c in catalog:
name = (c.get('nombre') or '').strip()
if not name:
continue
key = name.lower()
if key in existing:
courier_map[c['id']] = existing[key]
continue
cur.execute(
"INSERT INTO couriers (tenant_id, name, code, is_active) VALUES (%s, %s, %s, true) RETURNING id",
(tenant_id, name, f"MOT-{c['id']}")
)
new_id = cur.fetchone()[0]
existing[key] = new_id
courier_map[c['id']] = new_id
print(f"Created courier {c['id']}: {name}")
return courier_map
def ensure_articles(cur, branch_map):
"""Rached articles are kept as free-text lines, not inventory products."""
catalog = load_json('catalog_articulos')
article_map = {}
for a in catalog:
name = (a.get('nombre') or '').strip()
if name:
article_map[a['id']] = name
return article_map
def ensure_users(cur, branch_map, order_details):
# Map Rached user ids to employees
default_branch = get_default_branch(cur)
cur.execute("SELECT id, name FROM employees")
existing = {name.strip().lower(): id for id, name in cur.fetchall()}
user_map = {}
for d in order_details:
datos = d.get('datos', {})
uid = datos.get('idUsuario')
name = (datos.get('usuarioNombre') or '').strip()
if not uid or not name:
continue
key = name.lower()
if key in existing:
user_map[uid] = existing[key]
continue
cur.execute(
"INSERT INTO employees (name, role, branch_id, is_active) VALUES (%s, %s, %s, true) RETURNING id",
(name, 'workshop', default_branch)
)
new_id = cur.fetchone()[0]
existing[key] = new_id
user_map[uid] = new_id
print(f"Created user {uid}: {name}")
return user_map
def import_orders(cur, branch_map, client_map, mech_map, courier_map, article_map, user_map):
order_files = sorted(DATA_DIR.glob('details/order_*.json'))
imported = 0
skipped = 0
for f in order_files:
d = json.loads(f.read_text(encoding='utf-8'))
datos = d.get('datos', {})
order_number = datos.get('numero')
if not order_number:
continue
cur.execute("SELECT id FROM service_orders WHERE order_number = %s", (order_number,))
if cur.fetchone():
skipped += 1
continue
branch_id = branch_map.get(datos.get('idSucursal'))
customer_id = client_map.get(datos.get('idCliente'))
status = ORDER_STATUS_MAP.get(datos.get('idEstatus'), 'por_revisar')
# delivery method
via = datos.get('idViaEntrega')
mot_rec = datos.get('idMotociclistaRecoleccion')
mot_ent = datos.get('idMotociclistaEntrega')
courier_id = None
if via == 1:
delivery_method = 'pickup'
elif via == 2:
if mot_ent:
delivery_method = 'courier'
courier_id = courier_map.get(mot_ent)
elif mot_rec:
delivery_method = 'courier'
courier_id = courier_map.get(mot_rec)
else:
delivery_method = 'delivery'
else:
delivery_method = None
# customer address/phone from order overrides if present? order detail only has ids.
# Prefill from customer record (already stored in customers)
workshop_name = None
customer_phone = None
customer_address = None
if customer_id:
cur.execute("SELECT name, phone, address FROM customers WHERE id = %s", (customer_id,))
row = cur.fetchone()
if row:
workshop_name, customer_phone, customer_address = row
created_by = user_map.get(datos.get('idUsuario'))
fecha = datos.get('fecha')
created_at = datetime.strptime(fecha, '%Y-%m-%d %H:%M:%S') if fecha else datetime.now()
cur.execute(
"""
INSERT INTO service_orders
(tenant_id, branch_id, customer_id, order_number, status,
workshop_name, customer_address, customer_phone, vehicle_description,
reception_notes, estimated_cost, final_cost,
delivery_method, courier_id, is_direct, requires_invoice,
created_by, created_at, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(
31, # tenant_id for Rached
branch_id,
customer_id,
order_number,
status,
workshop_name,
customer_address,
customer_phone,
datos.get('vehiculo'),
datos.get('observaciones'),
datos.get('presupuesto') or 0,
datos.get('total') or 0,
delivery_method,
courier_id,
bool(datos.get('ordenDirecta')),
bool(datos.get('requiereFactura')),
created_by,
created_at,
created_at,
)
)
so_id = cur.fetchone()[0]
# status history
cur.execute(
"""
INSERT INTO service_order_status_history (service_order_id, new_status, changed_by, notes, created_at)
VALUES (%s, %s, %s, %s, %s)
""",
(so_id, status, created_by, 'Importado desde app Rached', created_at)
)
# items (free-text lines; not linked to inventory)
for it in datos.get('detalles', []):
articulo = it.get('articulo', {})
art_id = articulo.get('id')
art_name = article_map.get(art_id) or articulo.get('nombre') or 'Concepto'
mech_id = mech_map.get(it.get('idMecanico'))
item_status = ITEM_STATUS_MAP.get(it.get('idEstatusDetalle'), 'por_revisar')
qty = it.get('cantidad', 1)
price = it.get('precio') or 0
cur.execute(
"""
INSERT INTO service_order_items
(service_order_id, inventory_id, part_number, name, quantity,
unit_cost, unit_price, status, mechanic_id, observations)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
so_id,
None,
None,
art_name,
qty,
price,
price,
item_status,
mech_id,
it.get('observaciones'),
)
)
imported += 1
if imported % 100 == 0:
print(f"Imported {imported} orders...")
return imported, skipped
def main():
print(f"Importing Rached workshop data into {TENANT_DB}")
conn = connect()
cur = conn.cursor()
try:
branch_map = ensure_branches(cur)
print(f"Branch map: {branch_map}")
client_map = ensure_customers(cur, branch_map)
print(f"Customers to import: {len(client_map)}")
mech_catalog = load_json('catalog_mecanicos')
mech_map = ensure_employees(cur, branch_map, 'mechanic', mech_catalog)
print(f"Mechanics map: {len(mech_map)}")
courier_catalog = load_json('catalog_motociclistas')
courier_map = ensure_couriers(cur, courier_catalog, tenant_id=31)
print(f"Couriers map: {len(courier_map)}")
article_map = ensure_articles(cur, branch_map)
print(f"Articles map: {len(article_map)}")
order_details = [json.loads(f.read_text(encoding='utf-8')) for f in DATA_DIR.glob('details/order_*.json')]
user_map = ensure_users(cur, branch_map, order_details)
print(f"Users map: {len(user_map)}")
imported, skipped = import_orders(cur, branch_map, client_map, mech_map, courier_map, article_map, user_map)
print(f"Imported: {imported}, Skipped (already exist): {skipped}")
conn.commit()
except Exception as e:
conn.rollback()
print(f"ERROR: {e}")
raise
finally:
cur.close()
conn.close()
if __name__ == '__main__':
main()

Some files were not shown because too many files have changed in this diff Show More