feat(pos): add 3 improvements — Spanish translations, PDF quotes, push notifications

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/<id>/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) <noreply@anthropic.com>
This commit is contained in:
2026-04-04 08:05:11 +00:00
parent c61e58ac6a
commit 5d5a2777eb
11 changed files with 848 additions and 14 deletions

View File

@@ -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'], ''),
})

View File

@@ -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({

View File

@@ -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,

View File

@@ -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)."""

View File

@@ -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"""
<tr>
<td style="text-align:center">{i}</td>
<td>{qty}</td>
<td class="mono">{pn}</td>
<td>{name}</td>
<td class="right">${price:,.2f}</td>
<td class="right">{disc_str}</td>
<td class="right"><strong>${line_sub:,.2f}</strong></td>
</tr>"""
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"""<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>Cotizacion #{quot_id}</title>
<style>
@page {{
size: letter;
margin: 1.5cm;
}}
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{
font-family: 'Segoe UI', Arial, sans-serif;
font-size: 11pt;
color: #222;
line-height: 1.4;
padding: 20px;
max-width: 800px;
margin: 0 auto;
}}
.header {{
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 3px solid #1a237e;
padding-bottom: 12px;
margin-bottom: 16px;
}}
.header h1 {{
color: #1a237e;
font-size: 22pt;
margin-bottom: 4px;
}}
.header .biz-info {{
font-size: 9pt;
color: #555;
}}
.header .quot-info {{
text-align: right;
}}
.header .quot-number {{
font-size: 16pt;
font-weight: bold;
color: #1a237e;
}}
.header .quot-date {{
font-size: 9pt;
color: #555;
}}
.parties {{
display: flex;
gap: 30px;
margin-bottom: 16px;
}}
.parties .box {{
flex: 1;
border: 1px solid #ccc;
border-radius: 6px;
padding: 10px 14px;
}}
.parties .box h3 {{
font-size: 9pt;
text-transform: uppercase;
color: #888;
margin-bottom: 6px;
letter-spacing: 0.5px;
}}
.parties .box p {{
margin-bottom: 2px;
font-size: 10pt;
}}
.parties .box .name {{
font-weight: bold;
font-size: 11pt;
}}
table {{
width: 100%;
border-collapse: collapse;
margin-bottom: 16px;
}}
thead th {{
background: #1a237e;
color: white;
padding: 8px 10px;
font-size: 9pt;
text-transform: uppercase;
letter-spacing: 0.3px;
text-align: left;
}}
thead th.right {{ text-align: right; }}
tbody td {{
padding: 6px 10px;
border-bottom: 1px solid #e0e0e0;
font-size: 10pt;
vertical-align: top;
}}
tbody tr:nth-child(even) {{ background: #f8f9fa; }}
.right {{ text-align: right; }}
.mono {{ font-family: 'Courier New', monospace; font-size: 9pt; }}
.totals {{
width: 280px;
margin-left: auto;
border: 1px solid #ccc;
border-radius: 6px;
overflow: hidden;
margin-bottom: 20px;
}}
.totals tr td {{
padding: 6px 14px;
border-bottom: 1px solid #eee;
font-size: 10pt;
}}
.totals tr:last-child td {{
background: #1a237e;
color: white;
font-size: 13pt;
font-weight: bold;
padding: 10px 14px;
border-bottom: none;
}}
.totals .label {{ text-align: left; }}
.totals .value {{ text-align: right; }}
.footer {{
border-top: 1px solid #ccc;
padding-top: 12px;
font-size: 9pt;
color: #666;
}}
.footer .validity {{
background: #fff3e0;
border: 1px solid #ffb74d;
border-radius: 4px;
padding: 8px 12px;
margin-bottom: 10px;
color: #e65100;
font-weight: 500;
}}
.footer .notes {{
margin-bottom: 8px;
}}
.no-print {{
text-align: center;
margin-bottom: 20px;
}}
.no-print button {{
background: #1a237e;
color: white;
border: none;
padding: 10px 30px;
border-radius: 6px;
font-size: 12pt;
cursor: pointer;
}}
.no-print button:hover {{ background: #283593; }}
@media print {{
.no-print {{ display: none !important; }}
body {{ padding: 0; }}
.header {{ break-after: avoid; }}
tbody tr {{ break-inside: avoid; }}
}}
</style>
</head>
<body>
<div class="no-print">
<button onclick="window.print()">Imprimir / Guardar PDF</button>
</div>
<div class="header">
<div>
<h1>{biz_name}</h1>
<div class="biz-info">
{f'RFC: {biz_rfc}<br>' if biz_rfc else ''}
{f'{biz_address}<br>' if biz_address else ''}
{f'Tel: {biz_phone}<br>' if biz_phone else ''}
{f'{biz_email}' if biz_email else ''}
</div>
</div>
<div class="quot-info">
<div class="quot-number">COTIZACION #{quot_id}</div>
<div class="quot-date">
Fecha: {created_fmt}<br>
{f'Vendedor: {employee_name}' if employee_name else ''}
</div>
</div>
</div>
<div class="parties">
<div class="box">
<h3>Cliente</h3>
<p class="name">{cust_name}</p>
{f'<p>RFC: {cust_rfc}</p>' if cust_rfc else ''}
{f'<p>Tel: {cust_phone}</p>' if cust_phone else ''}
{f'<p>{cust_email}</p>' if cust_email else ''}
</div>
</div>
<table>
<thead>
<tr>
<th style="width:40px;text-align:center">#</th>
<th style="width:45px">Cant</th>
<th style="width:120px">No. Parte</th>
<th>Descripcion</th>
<th class="right" style="width:90px">P. Unit.</th>
<th class="right" style="width:60px">Desc.</th>
<th class="right" style="width:100px">Subtotal</th>
</tr>
</thead>
<tbody>
{items_html}
</tbody>
</table>
<table class="totals">
<tr><td class="label">Subtotal:</td><td class="value">${subtotal:,.2f}</td></tr>
<tr><td class="label">IVA (16%):</td><td class="value">${tax_total:,.2f}</td></tr>
<tr><td class="label">TOTAL:</td><td class="value">${total:,.2f} MXN</td></tr>
</table>
<div class="footer">
{f'<div class="validity">Cotizacion valida hasta: <strong>{valid_fmt}</strong>. Precios sujetos a cambio despues de esta fecha.</div>' if valid_fmt else ''}
{f'<div class="notes"><strong>Notas:</strong> {notes}</div>' if notes else ''}
<p style="text-align:center;margin-top:30px;color:#999">
Este documento es una cotizacion, no un comprobante fiscal.<br>
Generado por Nexus POS &mdash; {datetime.now().strftime('%d/%m/%Y %H:%M')}
</p>
</div>
</body>
</html>"""

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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)

95
pos/static/js/push.js Normal file
View File

@@ -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);
});
}
})();

55
pos/static/sw-push.js Normal file
View File

@@ -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);
}
})
);
});

View File

@@ -1478,6 +1478,7 @@
JAVASCRIPT
================================================================ -->
<script src="/pos/static/js/app-init.js"></script>
<script src="/pos/static/js/push.js"></script>
<script src="/pos/static/js/pos.js"></script>
<script>