diff --git a/dashboard/server.py b/dashboard/server.py
index 7f7afd0..7c38499 100644
--- a/dashboard/server.py
+++ b/dashboard/server.py
@@ -12,8 +12,10 @@ import urllib.request
from datetime import datetime, timedelta
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..'))
+sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'pos'))
from config import DB_URL
from auth import hash_password, check_password, create_access_token, create_refresh_token, decode_token, require_auth
+from services.translations import translate_part_name, translate_category
app = Flask(__name__, static_folder='.')
@@ -402,7 +404,7 @@ def api_catalog_categories():
ORDER BY name
"""), {'mye_id': mye_id}).mappings().all()
return jsonify([{'id_part_category': r['id_part_category'],
- 'name': r['name'], 'part_count': r['part_count']} for r in rows])
+ 'name': translate_category(r['name']), 'part_count': r['part_count']} for r in rows])
finally:
session.close()
@@ -428,7 +430,7 @@ def api_catalog_groups():
ORDER BY name
"""), {'mye_id': mye_id, 'category_id': category_id}).mappings().all()
return jsonify([{'id_part_group': r['id_part_group'],
- 'name': r['name'], 'part_count': r['part_count']} for r in rows])
+ 'name': translate_category(r['name']), 'part_count': r['part_count']} for r in rows])
finally:
session.close()
@@ -464,7 +466,7 @@ def api_catalog_parts():
items = [{
'id_part': r['id_part'],
'oem_part_number': r['oem_part_number'],
- 'name': r['name_es'] or r['name_part'],
+ 'name': translate_part_name(r['name_es'] or r['name_part']),
'description': r['description_es'] or r['description'],
'image_url': r['image_url'],
} for r in rows]
@@ -497,11 +499,11 @@ def api_catalog_part_detail(part_id):
part = {
'id_part': row['id_part'],
'oem_part_number': row['oem_part_number'],
- 'name': row['name_es'] or row['name_part'],
+ 'name': translate_part_name(row['name_es'] or row['name_part']),
'description': row['description_es'] or row['description'],
'image_url': row['image_url'],
- 'group_name': row['group_name'],
- 'category_name': row['category_name'],
+ 'group_name': translate_category(row['group_name']) if row['group_name'] else row['group_name'],
+ 'category_name': translate_category(row['category_name']) if row['category_name'] else row['category_name'],
}
# Cross-references
@@ -615,7 +617,7 @@ def api_catalog_search():
results.append({
'id_part': r['id_part'],
'oem_part_number': r['oem_part_number'],
- 'name': r['name_es'] or r['name_part'],
+ 'name': translate_part_name(r['name_es'] or r['name_part']),
'image_url': r['image_url'],
'vehicle_info': vmap.get(r['id_part'], ''),
})
diff --git a/pos/blueprints/cashregister_bp.py b/pos/blueprints/cashregister_bp.py
index 911f6c7..86e1795 100644
--- a/pos/blueprints/cashregister_bp.py
+++ b/pos/blueprints/cashregister_bp.py
@@ -345,6 +345,21 @@ def cut_z():
})
conn.commit()
+
+ # Push notification to owner if cash difference > $500
+ if abs(difference) > 500:
+ try:
+ from services.push_service import notify_owner
+ emp_name = getattr(g, 'employee_name', 'Empleado')
+ notify_owner(
+ conn,
+ 'Diferencia en Caja',
+ f'Corte Z caja #{register_id}: diferencia de ${difference:,.2f} ({emp_name})',
+ '/pos'
+ )
+ except Exception:
+ pass # Push failures never block business logic
+
cur.close(); conn.close()
return jsonify({
diff --git a/pos/services/catalog_service.py b/pos/services/catalog_service.py
index bb181bd..e994942 100644
--- a/pos/services/catalog_service.py
+++ b/pos/services/catalog_service.py
@@ -13,6 +13,7 @@ PERFORMANCE: vehicle_parts has 14B+ rows. Every query MUST:
import re
from services.na_models import is_na_model
+from services.translations import translate_part_name, translate_category
def _clean_model_name(name):
@@ -185,7 +186,7 @@ def get_categories(master_conn, mye_id):
""", (mye_id,))
rows = cur.fetchall()
cur.close()
- return [{'id_part_category': r[0], 'name': r[1], 'part_count': r[2]} for r in rows]
+ return [{'id_part_category': r[0], 'name': translate_category(r[1]), 'part_count': r[2]} for r in rows]
def get_groups(master_conn, mye_id, category_id):
@@ -273,10 +274,11 @@ def get_parts(master_conn, mye_id, group_id, tenant_conn, branch_id, page=1, per
local = local_map.get(oem) or local_map.get(f'cat:{part_id}')
# Prefer local inventory image over catalog image
image_url = (local.get('image_url') if local else None) or r[6]
+ raw_name = r[3] or r[2] # prefer Spanish name
items.append({
'id_part': part_id,
'oem_part_number': oem,
- 'name': r[3] or r[2], # prefer Spanish name
+ 'name': translate_part_name(raw_name),
'description': r[5] or r[4],
'image_url': image_url,
'local_stock': local['stock'] if local else 0,
@@ -321,11 +323,11 @@ def get_part_detail(master_conn, part_id, tenant_conn, branch_id):
part_info = {
'id_part': row[0],
'oem_part_number': oem,
- 'name': row[3] or row[2],
+ 'name': translate_part_name(row[3] or row[2]),
'description': row[5] or row[4],
'image_url': row[6],
- 'group_name': row[7],
- 'category_name': row[8],
+ 'group_name': translate_category(row[7]) if row[7] else row[7],
+ 'category_name': translate_category(row[8]) if row[8] else row[8],
}
# Bodegas with stock
@@ -517,7 +519,7 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50):
results.append({
'id_part': part_id,
'oem_part_number': oem,
- 'name': r[3] or r[2],
+ 'name': translate_part_name(r[3] or r[2]),
'image_url': r[4],
'local_stock': local['stock'] if local else 0,
'local_price': local['price_1'] if local else None,
diff --git a/pos/services/inventory_engine.py b/pos/services/inventory_engine.py
index 20cf1ab..b2e5489 100644
--- a/pos/services/inventory_engine.py
+++ b/pos/services/inventory_engine.py
@@ -120,11 +120,32 @@ def record_sale(conn, inventory_id, branch_id, quantity, sale_id=None, cost_at_t
NOT exposed via HTTP endpoint — called directly by the POS blueprint (Plan 3)
which imports inventory_engine as part of the full sale transaction.
"""
- return record_operation(
+ op_id = record_operation(
conn, inventory_id, branch_id, 'SALE', -abs(quantity),
reference_id=sale_id, reference_type='sale', cost_at_time=cost_at_time
)
+ # Check if stock hit zero — push to owner (best-effort)
+ try:
+ remaining = 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 # Push failures never block sales
+
+ return op_id
+
def record_return(conn, inventory_id, branch_id, quantity, sale_id=None, notes=None):
"""Record a customer return (positive quantity)."""
diff --git a/pos/services/pdf_generator.py b/pos/services/pdf_generator.py
new file mode 100644
index 0000000..3f86887
--- /dev/null
+++ b/pos/services/pdf_generator.py
@@ -0,0 +1,308 @@
+# /home/Autopartes/pos/services/pdf_generator.py
+"""Generate printable HTML for quotations (browser print-to-PDF).
+
+Returns self-contained HTML with @media print CSS for clean PDF output.
+No external dependencies required — uses the browser's built-in print.
+"""
+
+from datetime import datetime
+
+
+def generate_quote_html(quotation, items, business_info=None, customer_info=None):
+ """Generate printable HTML for a quotation.
+
+ Args:
+ quotation: dict with keys: id, subtotal, tax_total, total, valid_until, created_at, notes, employee_name
+ items: list of dicts: part_number, name, quantity, unit_price, discount_pct, tax_rate, subtotal
+ business_info: dict with keys: name, rfc, address, phone, email (optional)
+ customer_info: dict with keys: name, rfc, phone, email (optional)
+
+ Returns:
+ str: Complete HTML document ready for printing
+ """
+ biz = business_info or {}
+ biz_name = biz.get('name', 'Autopartes Nexus')
+ biz_rfc = biz.get('rfc', '')
+ biz_address = biz.get('address', '')
+ biz_phone = biz.get('phone', '')
+ biz_email = biz.get('email', '')
+
+ cust = customer_info or {}
+ cust_name = cust.get('name', 'Publico en General')
+ cust_rfc = cust.get('rfc', 'XAXX010101000')
+ cust_phone = cust.get('phone', '')
+ cust_email = cust.get('email', '')
+
+ quot_id = quotation.get('id', 0)
+ created = quotation.get('created_at', '')
+ valid_until = quotation.get('valid_until', '')
+ notes = quotation.get('notes', '')
+ employee_name = quotation.get('employee_name', '')
+
+ subtotal = float(quotation.get('subtotal', 0))
+ tax_total = float(quotation.get('tax_total', 0))
+ total = float(quotation.get('total', 0))
+
+ # Build items rows
+ items_html = ''
+ for i, item in enumerate(items, 1):
+ qty = item.get('quantity', 1)
+ price = float(item.get('unit_price', 0))
+ disc = float(item.get('discount_pct', 0))
+ line_sub = float(item.get('subtotal', qty * price))
+ pn = item.get('part_number', '')
+ name = item.get('name', '')
+
+ disc_str = f'{disc:.0f}%' if disc > 0 else '-'
+
+ items_html += f"""
+
+ | {i} |
+ {qty} |
+ {pn} |
+ {name} |
+ ${price:,.2f} |
+ {disc_str} |
+ ${line_sub:,.2f} |
+
"""
+
+ try:
+ created_fmt = datetime.fromisoformat(str(created).replace('Z', '+00:00')).strftime('%d/%m/%Y %H:%M')
+ except Exception:
+ created_fmt = str(created)[:16] if created else ''
+
+ try:
+ valid_fmt = datetime.fromisoformat(str(valid_until)).strftime('%d/%m/%Y')
+ except Exception:
+ valid_fmt = str(valid_until)[:10] if valid_until else ''
+
+ return f"""
+
+
+
+Cotizacion #{quot_id}
+
+
+
+
+
+
+
+
+
+
+
+
Cliente
+
{cust_name}
+ {f'
RFC: {cust_rfc}
' if cust_rfc else ''}
+ {f'
Tel: {cust_phone}
' if cust_phone else ''}
+ {f'
{cust_email}
' if cust_email else ''}
+
+
+
+
+
+
+ | # |
+ Cant |
+ No. Parte |
+ Descripcion |
+ P. Unit. |
+ Desc. |
+ Subtotal |
+
+
+
+ {items_html}
+
+
+
+
+ | Subtotal: | ${subtotal:,.2f} |
+ | IVA (16%): | ${tax_total:,.2f} |
+ | TOTAL: | ${total:,.2f} MXN |
+
+
+
+
+"""
diff --git a/pos/services/pos_engine.py b/pos/services/pos_engine.py
index c43d981..3cefd7e 100644
--- a/pos/services/pos_engine.py
+++ b/pos/services/pos_engine.py
@@ -510,6 +510,19 @@ def cancel_sale(conn, sale_id, reason):
old_value={'status': 'completed', 'total': float(s_total)},
new_value={'status': 'cancelled', 'reason': reason})
+ # Push notification to owner/admin (best-effort, non-blocking)
+ try:
+ from services.push_service import notify_owner
+ emp_name = getattr(g, 'employee_name', 'Empleado')
+ notify_owner(
+ conn,
+ 'Venta Cancelada',
+ f'Venta #{sale_id} (${float(s_total):,.2f}) cancelada por {emp_name}: {reason}',
+ '/pos'
+ )
+ except Exception:
+ pass # Push failures never block business logic
+
cur.close()
return {
diff --git a/pos/services/push_service.py b/pos/services/push_service.py
new file mode 100644
index 0000000..1a66b70
--- /dev/null
+++ b/pos/services/push_service.py
@@ -0,0 +1,215 @@
+# /home/Autopartes/pos/services/push_service.py
+"""Web Push notification service for Nexus POS.
+
+Uses the pywebpush library to send push notifications via the Web Push API.
+VAPID keys are generated once and stored in the tenant config table.
+
+Usage:
+ from services.push_service import notify_owner
+
+ # Non-blocking: fire-and-forget push to owner(s)
+ notify_owner(conn, 'Venta Cancelada', f'Venta #{sale_id} cancelada por {reason}', '/pos')
+"""
+
+import json
+import logging
+import os
+import traceback
+
+logger = logging.getLogger(__name__)
+
+# Try to import pywebpush — graceful degradation if not installed
+try:
+ from pywebpush import webpush, WebPushException
+ HAS_WEBPUSH = True
+except ImportError:
+ HAS_WEBPUSH = False
+ logger.warning("pywebpush not installed — push notifications disabled. Install with: pip install pywebpush")
+
+try:
+ from py_vapid import Vapid
+ HAS_VAPID = True
+except ImportError:
+ HAS_VAPID = False
+
+
+def get_or_create_vapid_keys(conn):
+ """Get VAPID keys from config, or generate new ones.
+
+ Returns: (private_key_pem, public_key_b64url) or (None, None) if unavailable.
+ """
+ if not HAS_VAPID and not HAS_WEBPUSH:
+ return None, None
+
+ cur = conn.cursor()
+
+ # Check if keys already exist in config
+ try:
+ cur.execute("SELECT value FROM config WHERE key = 'vapid_private_key'")
+ row = cur.fetchone()
+ if row:
+ private_key = row[0]
+ cur.execute("SELECT value FROM config WHERE key = 'vapid_public_key'")
+ pub_row = cur.fetchone()
+ public_key = pub_row[0] if pub_row else None
+ cur.close()
+ if public_key:
+ return private_key, public_key
+ except Exception:
+ pass # config table might not exist yet
+
+ # Generate new VAPID keys
+ try:
+ vapid = Vapid()
+ vapid.generate_keys()
+ private_pem = vapid.private_pem().decode('utf-8')
+ public_b64 = vapid.public_key_urlsafe_base64()
+
+ # Store in config table
+ try:
+ cur.execute("""
+ INSERT INTO config (key, value) VALUES ('vapid_private_key', %s)
+ ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
+ """, (private_pem,))
+ cur.execute("""
+ INSERT INTO config (key, value) VALUES ('vapid_public_key', %s)
+ ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
+ """, (public_b64,))
+ conn.commit()
+ except Exception:
+ conn.rollback()
+
+ cur.close()
+ return private_pem, public_b64
+ except Exception as e:
+ logger.error(f"Failed to generate VAPID keys: {e}")
+ cur.close()
+ return None, None
+
+
+def save_subscription(conn, employee_id, subscription_json):
+ """Save a push subscription for an employee.
+
+ Args:
+ conn: psycopg2 connection
+ employee_id: int
+ subscription_json: dict (the PushSubscription object from the browser)
+ """
+ cur = conn.cursor()
+ sub_str = json.dumps(subscription_json) if isinstance(subscription_json, dict) else subscription_json
+
+ # Upsert: one subscription per employee
+ cur.execute("""
+ INSERT INTO push_subscriptions (employee_id, subscription_data, created_at)
+ VALUES (%s, %s, NOW())
+ ON CONFLICT (employee_id) DO UPDATE
+ SET subscription_data = EXCLUDED.subscription_data, created_at = NOW()
+ """, (employee_id, sub_str))
+ conn.commit()
+ cur.close()
+
+
+def send_push(conn, employee_id, title, body, url=None):
+ """Send a push notification to a specific employee.
+
+ Returns True on success, False on failure.
+ """
+ if not HAS_WEBPUSH:
+ logger.debug("pywebpush not available, skipping push")
+ return False
+
+ cur = conn.cursor()
+
+ # Get subscription
+ cur.execute("SELECT subscription_data FROM push_subscriptions WHERE employee_id = %s", (employee_id,))
+ row = cur.fetchone()
+ if not row:
+ cur.close()
+ return False
+
+ subscription = json.loads(row[0]) if isinstance(row[0], str) else row[0]
+
+ # Get VAPID keys
+ private_key, public_key = get_or_create_vapid_keys(conn)
+ if not private_key:
+ cur.close()
+ return False
+
+ cur.close()
+
+ payload = json.dumps({
+ 'title': title,
+ 'body': body,
+ 'url': url or '/pos',
+ 'icon': '/pos/static/icons/icon-192.png',
+ 'badge': '/pos/static/icons/badge-72.png',
+ })
+
+ try:
+ webpush(
+ subscription_info=subscription,
+ data=payload,
+ vapid_private_key=private_key,
+ vapid_claims={'sub': 'mailto:notificaciones@nexusautoparts.mx'},
+ )
+ return True
+ except WebPushException as e:
+ logger.warning(f"Push failed for employee {employee_id}: {e}")
+ # If subscription expired, remove it
+ if e.response and e.response.status_code in (404, 410):
+ cur2 = conn.cursor()
+ cur2.execute("DELETE FROM push_subscriptions WHERE employee_id = %s", (employee_id,))
+ conn.commit()
+ cur2.close()
+ return False
+ except Exception as e:
+ logger.warning(f"Push error: {e}")
+ return False
+
+
+def notify_owner(conn, title, body, url=None):
+ """Send push notification to all owner/admin employees (non-blocking, best-effort).
+
+ This is the main integration point — call from sale cancellation, stock alerts, etc.
+ """
+ if not HAS_WEBPUSH:
+ return
+
+ cur = conn.cursor()
+ try:
+ cur.execute("""
+ SELECT ps.employee_id
+ FROM push_subscriptions ps
+ JOIN employees e ON e.id = ps.employee_id
+ WHERE e.role IN ('owner', 'admin') AND e.is_active = true
+ """)
+ employee_ids = [r[0] for r in cur.fetchall()]
+ cur.close()
+ except Exception:
+ cur.close()
+ return
+
+ for eid in employee_ids:
+ try:
+ send_push(conn, eid, title, body, url)
+ except Exception:
+ pass # Never let push failures affect business logic
+
+
+def ensure_push_table(conn):
+ """Create push_subscriptions table if it doesn't exist."""
+ cur = conn.cursor()
+ try:
+ cur.execute("""
+ CREATE TABLE IF NOT EXISTS push_subscriptions (
+ id SERIAL PRIMARY KEY,
+ employee_id INTEGER NOT NULL UNIQUE,
+ subscription_data TEXT NOT NULL,
+ created_at TIMESTAMP DEFAULT NOW()
+ )
+ """)
+ conn.commit()
+ except Exception:
+ conn.rollback()
+ finally:
+ cur.close()
diff --git a/pos/services/translations.py b/pos/services/translations.py
new file mode 100644
index 0000000..e9915e0
--- /dev/null
+++ b/pos/services/translations.py
@@ -0,0 +1,107 @@
+# /home/Autopartes/pos/services/translations.py
+"""Spanish translations for TecDoc catalog part names and categories.
+
+Uses a dictionary of common English→Spanish auto part translations.
+Falls back to the original name if no match is found.
+"""
+
+PART_TRANSLATIONS = {
+ 'Brake Pad Set': 'Juego de Balatas',
+ 'Brake Disc': 'Disco de Freno',
+ 'Shock Absorber': 'Amortiguador',
+ 'Oil Filter': 'Filtro de Aceite',
+ 'Air Filter': 'Filtro de Aire',
+ 'Spark Plug': 'Bujía',
+ 'Water Pump': 'Bomba de Agua',
+ 'Alternator': 'Alternador',
+ 'Starter Motor': 'Motor de Arranque',
+ 'Radiator': 'Radiador',
+ 'Thermostat': 'Termostato',
+ 'Timing Belt': 'Banda de Distribución',
+ 'V-Belt': 'Banda Serpentina',
+ 'Serpentine Belt': 'Banda Serpentina',
+ 'Clutch Kit': 'Kit de Embrague',
+ 'Fuel Pump': 'Bomba de Gasolina',
+ 'Fuel Filter': 'Filtro de Gasolina',
+ 'Oxygen Sensor': 'Sensor de Oxígeno',
+ 'Ignition Coil': 'Bobina de Encendido',
+ 'Wheel Bearing': 'Balero de Rueda',
+ 'Tie Rod End': 'Terminal de Dirección',
+ 'Ball Joint': 'Rótula',
+ 'CV Joint': 'Junta Homocinética',
+ 'Wiper Blade': 'Pluma Limpiaparabrisas',
+ 'Battery': 'Batería',
+ 'Headlight': 'Faro Delantero',
+ 'Tail Light': 'Calavera Trasera',
+ 'Mirror': 'Espejo',
+ 'Muffler': 'Mofle',
+ 'Exhaust Pipe': 'Tubo de Escape',
+ 'Catalytic Converter': 'Catalizador',
+ 'Piston': 'Pistón',
+ 'Gasket': 'Junta/Empaque',
+ 'Valve': 'Válvula',
+ 'Camshaft': 'Árbol de Levas',
+ 'Crankshaft': 'Cigüeñal',
+ 'Connecting Rod': 'Biela',
+ 'Engine Mount': 'Soporte de Motor',
+ 'Transmission Mount': 'Soporte de Transmisión',
+ 'Control Arm': 'Brazo de Suspensión',
+ 'Strut': 'Puntal',
+ 'Spring': 'Resorte',
+ 'Stabilizer Bar': 'Barra Estabilizadora',
+ 'Brake Caliper': 'Caliper de Freno',
+ 'Brake Drum': 'Tambor de Freno',
+ 'Brake Hose': 'Manguera de Freno',
+ 'Master Cylinder': 'Cilindro Maestro',
+ 'Wheel Cylinder': 'Cilindro de Rueda',
+ 'Power Steering Pump': 'Bomba de Dirección Hidráulica',
+ 'Rack and Pinion': 'Cremallera de Dirección',
+ 'A/C Compressor': 'Compresor de Aire Acondicionado',
+ 'Condenser': 'Condensador',
+ 'Evaporator': 'Evaporador',
+ 'Heater Core': 'Radiador de Calefacción',
+ 'Blower Motor': 'Motor de Ventilador',
+ 'Tensioner': 'Tensor',
+ 'Idler Pulley': 'Polea Loca',
+ 'Flywheel': 'Volante de Motor',
+ 'Injector': 'Inyector',
+ 'Throttle Body': 'Cuerpo de Aceleración',
+ 'Mass Air Flow Sensor': 'Sensor MAF',
+ 'Coolant': 'Anticongelante',
+ 'Brake Fluid': 'Líquido de Frenos',
+ 'Transmission Fluid': 'Aceite de Transmisión',
+ 'Engine Oil': 'Aceite de Motor',
+ # Categories
+ 'Braking System': 'Sistema de Frenos',
+ 'Engine': 'Motor',
+ 'Suspension/Damping': 'Suspensión',
+ 'Electrics': 'Eléctrico',
+ 'Cooling System': 'Sistema de Enfriamiento',
+ 'Exhaust System': 'Sistema de Escape',
+ 'Fuel Mixture Formation': 'Sistema de Combustible',
+ 'Steering': 'Dirección',
+ 'Filters': 'Filtros',
+ 'Belt Drive': 'Bandas y Poleas',
+ 'Spark/Glow Ignition': 'Encendido',
+ 'Heating/Ventilation': 'Calefacción/Ventilación',
+ 'Maintenance Service Parts': 'Partes de Mantenimiento',
+ 'Axle Drive': 'Transmisión/Ejes',
+ 'Body': 'Carrocería',
+ 'Axle Mounting/ Steering/ Wheels': 'Suspensión/Dirección/Ruedas',
+}
+
+
+def translate_part_name(name):
+ """Translate a part name from English to Spanish. Uses partial matching."""
+ if not name:
+ return name
+ name_upper = name.upper()
+ for en, es in PART_TRANSLATIONS.items():
+ if en.upper() in name_upper:
+ return name.replace(en, es).replace(en.lower(), es.lower()).replace(en.upper(), es.upper())
+ return name
+
+
+def translate_category(name):
+ """Translate a category name."""
+ return PART_TRANSLATIONS.get(name, name)
diff --git a/pos/static/js/push.js b/pos/static/js/push.js
new file mode 100644
index 0000000..7caabd2
--- /dev/null
+++ b/pos/static/js/push.js
@@ -0,0 +1,95 @@
+/**
+ * push.js — Web Push notification setup for Nexus POS
+ *
+ * Registers a service worker and subscribes to push notifications.
+ * Only activates for owner/admin roles.
+ */
+(function() {
+ 'use strict';
+
+ // Only set up push for owner/admin
+ var employee = {};
+ try { employee = JSON.parse(localStorage.getItem('pos_employee') || '{}'); } catch(e) {}
+ var role = employee.role || '';
+ if (role !== 'owner' && role !== 'admin') return;
+
+ // Check browser support
+ if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
+ console.log('[Push] Browser does not support push notifications');
+ return;
+ }
+
+ var token = localStorage.getItem('pos_token');
+ if (!token) return;
+
+ function urlBase64ToUint8Array(base64String) {
+ var padding = '='.repeat((4 - base64String.length % 4) % 4);
+ var base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
+ var rawData = window.atob(base64);
+ var outputArray = new Uint8Array(rawData.length);
+ for (var i = 0; i < rawData.length; ++i) {
+ outputArray[i] = rawData.charCodeAt(i);
+ }
+ return outputArray;
+ }
+
+ async function setupPush() {
+ try {
+ // Register service worker
+ var registration = await navigator.serviceWorker.register('/pos/static/sw-push.js', {
+ scope: '/pos/'
+ });
+ console.log('[Push] Service worker registered');
+
+ // Get VAPID key from server
+ var resp = await fetch('/pos/api/push/vapid-key', {
+ headers: { 'Authorization': 'Bearer ' + token }
+ });
+ if (!resp.ok) {
+ console.log('[Push] VAPID key not available:', resp.status);
+ return;
+ }
+ var data = await resp.json();
+ var vapidKey = data.public_key;
+ if (!vapidKey) return;
+
+ // Request permission
+ var permission = await Notification.requestPermission();
+ if (permission !== 'granted') {
+ console.log('[Push] Permission denied');
+ return;
+ }
+
+ // Subscribe
+ var subscription = await registration.pushManager.subscribe({
+ userVisibleOnly: true,
+ applicationServerKey: urlBase64ToUint8Array(vapidKey)
+ });
+
+ // Send subscription to server
+ var subResp = await fetch('/pos/api/push/subscribe', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': 'Bearer ' + token
+ },
+ body: JSON.stringify({ subscription: subscription.toJSON() })
+ });
+
+ if (subResp.ok) {
+ console.log('[Push] Subscribed successfully');
+ }
+ } catch(err) {
+ console.log('[Push] Setup error:', err);
+ }
+ }
+
+ // Delay push setup to not block page load
+ if (document.readyState === 'complete') {
+ setTimeout(setupPush, 2000);
+ } else {
+ window.addEventListener('load', function() {
+ setTimeout(setupPush, 2000);
+ });
+ }
+})();
diff --git a/pos/static/sw-push.js b/pos/static/sw-push.js
new file mode 100644
index 0000000..990c413
--- /dev/null
+++ b/pos/static/sw-push.js
@@ -0,0 +1,55 @@
+/**
+ * sw-push.js — Service Worker for Nexus POS push notifications
+ */
+
+self.addEventListener('push', function(event) {
+ var data = { title: 'Nexus POS', body: '', url: '/pos', icon: '/pos/static/icons/icon-192.png' };
+
+ if (event.data) {
+ try {
+ data = Object.assign(data, event.data.json());
+ } catch(e) {
+ data.body = event.data.text();
+ }
+ }
+
+ var options = {
+ body: data.body,
+ icon: data.icon || '/pos/static/icons/icon-192.png',
+ badge: data.badge || '/pos/static/icons/badge-72.png',
+ vibrate: [200, 100, 200],
+ data: { url: data.url || '/pos' },
+ actions: [
+ { action: 'open', title: 'Ver' },
+ { action: 'dismiss', title: 'Cerrar' }
+ ]
+ };
+
+ event.waitUntil(
+ self.registration.showNotification(data.title, options)
+ );
+});
+
+self.addEventListener('notificationclick', function(event) {
+ event.notification.close();
+
+ if (event.action === 'dismiss') return;
+
+ var url = (event.notification.data && event.notification.data.url) || '/pos';
+
+ event.waitUntil(
+ clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function(clientList) {
+ // Focus existing window if open
+ for (var i = 0; i < clientList.length; i++) {
+ var client = clientList[i];
+ if (client.url.indexOf('/pos') !== -1 && 'focus' in client) {
+ return client.focus();
+ }
+ }
+ // Open new window
+ if (clients.openWindow) {
+ return clients.openWindow(url);
+ }
+ })
+ );
+});
diff --git a/pos/templates/pos.html b/pos/templates/pos.html
index 7b80261..49b123e 100644
--- a/pos/templates/pos.html
+++ b/pos/templates/pos.html
@@ -1478,6 +1478,7 @@
JAVASCRIPT
================================================================ -->
+