From 5d5a2777ebe010f7d6c5308fb271f58baf1c707c Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Sat, 4 Apr 2026 08:05:11 +0000 Subject: [PATCH] =?UTF-8?q?feat(pos):=20add=203=20improvements=20=E2=80=94?= =?UTF-8?q?=20Spanish=20translations,=20PDF=20quotes,=20push=20notificatio?= =?UTF-8?q?ns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Spanish translations for TecDoc catalog (translations.py) applied to catalog_service.py and dashboard server.py endpoints 2. Printable quotation HTML endpoint (/pos/api/quotations//pdf) with @media print CSS for clean browser-to-PDF output 3. Web Push notifications to owner/admin on sale cancellation, stock zero, and cash register differences > $500. Includes service worker, VAPID key management, and subscription endpoints. Co-Authored-By: Claude Opus 4.6 (1M context) --- dashboard/server.py | 16 +- pos/blueprints/cashregister_bp.py | 15 ++ pos/services/catalog_service.py | 14 +- pos/services/inventory_engine.py | 23 ++- pos/services/pdf_generator.py | 308 ++++++++++++++++++++++++++++++ pos/services/pos_engine.py | 13 ++ pos/services/push_service.py | 215 +++++++++++++++++++++ pos/services/translations.py | 107 +++++++++++ pos/static/js/push.js | 95 +++++++++ pos/static/sw-push.js | 55 ++++++ pos/templates/pos.html | 1 + 11 files changed, 848 insertions(+), 14 deletions(-) create mode 100644 pos/services/pdf_generator.py create mode 100644 pos/services/push_service.py create mode 100644 pos/services/translations.py create mode 100644 pos/static/js/push.js create mode 100644 pos/static/sw-push.js 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} + + + +
+ +
+ +
+
+

{biz_name}

+
+ {f'RFC: {biz_rfc}
' if biz_rfc else ''} + {f'{biz_address}
' if biz_address else ''} + {f'Tel: {biz_phone}
' if biz_phone else ''} + {f'{biz_email}' if biz_email else ''} +
+
+
+
COTIZACION #{quot_id}
+
+ Fecha: {created_fmt}
+ {f'Vendedor: {employee_name}' if employee_name else ''} +
+
+
+ +
+
+

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 ''} +
+
+ + + + + + + + + + + + + + + {items_html} + +
#CantNo. ParteDescripcionP. Unit.Desc.Subtotal
+ + + + + +
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 ================================================================ --> +