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:
@@ -12,8 +12,10 @@ import urllib.request
|
|||||||
from datetime import datetime, timedelta
|
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__)), '..'))
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'pos'))
|
||||||
from config import DB_URL
|
from config import DB_URL
|
||||||
from auth import hash_password, check_password, create_access_token, create_refresh_token, decode_token, require_auth
|
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='.')
|
app = Flask(__name__, static_folder='.')
|
||||||
|
|
||||||
@@ -402,7 +404,7 @@ def api_catalog_categories():
|
|||||||
ORDER BY name
|
ORDER BY name
|
||||||
"""), {'mye_id': mye_id}).mappings().all()
|
"""), {'mye_id': mye_id}).mappings().all()
|
||||||
return jsonify([{'id_part_category': r['id_part_category'],
|
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:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
@@ -428,7 +430,7 @@ def api_catalog_groups():
|
|||||||
ORDER BY name
|
ORDER BY name
|
||||||
"""), {'mye_id': mye_id, 'category_id': category_id}).mappings().all()
|
"""), {'mye_id': mye_id, 'category_id': category_id}).mappings().all()
|
||||||
return jsonify([{'id_part_group': r['id_part_group'],
|
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:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
@@ -464,7 +466,7 @@ def api_catalog_parts():
|
|||||||
items = [{
|
items = [{
|
||||||
'id_part': r['id_part'],
|
'id_part': r['id_part'],
|
||||||
'oem_part_number': r['oem_part_number'],
|
'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'],
|
'description': r['description_es'] or r['description'],
|
||||||
'image_url': r['image_url'],
|
'image_url': r['image_url'],
|
||||||
} for r in rows]
|
} for r in rows]
|
||||||
@@ -497,11 +499,11 @@ def api_catalog_part_detail(part_id):
|
|||||||
part = {
|
part = {
|
||||||
'id_part': row['id_part'],
|
'id_part': row['id_part'],
|
||||||
'oem_part_number': row['oem_part_number'],
|
'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'],
|
'description': row['description_es'] or row['description'],
|
||||||
'image_url': row['image_url'],
|
'image_url': row['image_url'],
|
||||||
'group_name': row['group_name'],
|
'group_name': translate_category(row['group_name']) if row['group_name'] else row['group_name'],
|
||||||
'category_name': row['category_name'],
|
'category_name': translate_category(row['category_name']) if row['category_name'] else row['category_name'],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Cross-references
|
# Cross-references
|
||||||
@@ -615,7 +617,7 @@ def api_catalog_search():
|
|||||||
results.append({
|
results.append({
|
||||||
'id_part': r['id_part'],
|
'id_part': r['id_part'],
|
||||||
'oem_part_number': r['oem_part_number'],
|
'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'],
|
'image_url': r['image_url'],
|
||||||
'vehicle_info': vmap.get(r['id_part'], ''),
|
'vehicle_info': vmap.get(r['id_part'], ''),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -345,6 +345,21 @@ def cut_z():
|
|||||||
})
|
})
|
||||||
|
|
||||||
conn.commit()
|
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()
|
cur.close(); conn.close()
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ PERFORMANCE: vehicle_parts has 14B+ rows. Every query MUST:
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from services.na_models import is_na_model
|
from services.na_models import is_na_model
|
||||||
|
from services.translations import translate_part_name, translate_category
|
||||||
|
|
||||||
|
|
||||||
def _clean_model_name(name):
|
def _clean_model_name(name):
|
||||||
@@ -185,7 +186,7 @@ def get_categories(master_conn, mye_id):
|
|||||||
""", (mye_id,))
|
""", (mye_id,))
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
cur.close()
|
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):
|
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}')
|
local = local_map.get(oem) or local_map.get(f'cat:{part_id}')
|
||||||
# Prefer local inventory image over catalog image
|
# Prefer local inventory image over catalog image
|
||||||
image_url = (local.get('image_url') if local else None) or r[6]
|
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({
|
items.append({
|
||||||
'id_part': part_id,
|
'id_part': part_id,
|
||||||
'oem_part_number': oem,
|
'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],
|
'description': r[5] or r[4],
|
||||||
'image_url': image_url,
|
'image_url': image_url,
|
||||||
'local_stock': local['stock'] if local else 0,
|
'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 = {
|
part_info = {
|
||||||
'id_part': row[0],
|
'id_part': row[0],
|
||||||
'oem_part_number': oem,
|
'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],
|
'description': row[5] or row[4],
|
||||||
'image_url': row[6],
|
'image_url': row[6],
|
||||||
'group_name': row[7],
|
'group_name': translate_category(row[7]) if row[7] else row[7],
|
||||||
'category_name': row[8],
|
'category_name': translate_category(row[8]) if row[8] else row[8],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Bodegas with stock
|
# Bodegas with stock
|
||||||
@@ -517,7 +519,7 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50):
|
|||||||
results.append({
|
results.append({
|
||||||
'id_part': part_id,
|
'id_part': part_id,
|
||||||
'oem_part_number': oem,
|
'oem_part_number': oem,
|
||||||
'name': r[3] or r[2],
|
'name': translate_part_name(r[3] or r[2]),
|
||||||
'image_url': r[4],
|
'image_url': r[4],
|
||||||
'local_stock': local['stock'] if local else 0,
|
'local_stock': local['stock'] if local else 0,
|
||||||
'local_price': local['price_1'] if local else None,
|
'local_price': local['price_1'] if local else None,
|
||||||
|
|||||||
@@ -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)
|
NOT exposed via HTTP endpoint — called directly by the POS blueprint (Plan 3)
|
||||||
which imports inventory_engine as part of the full sale transaction.
|
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),
|
conn, inventory_id, branch_id, 'SALE', -abs(quantity),
|
||||||
reference_id=sale_id, reference_type='sale', cost_at_time=cost_at_time
|
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):
|
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)."""
|
||||||
|
|||||||
308
pos/services/pdf_generator.py
Normal file
308
pos/services/pdf_generator.py
Normal 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 — {datetime.now().strftime('%d/%m/%Y %H:%M')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
@@ -510,6 +510,19 @@ def cancel_sale(conn, sale_id, reason):
|
|||||||
old_value={'status': 'completed', '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)
|
||||||
|
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()
|
cur.close()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
215
pos/services/push_service.py
Normal file
215
pos/services/push_service.py
Normal 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()
|
||||||
107
pos/services/translations.py
Normal file
107
pos/services/translations.py
Normal 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
95
pos/static/js/push.js
Normal 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
55
pos/static/sw-push.js
Normal 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);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1478,6 +1478,7 @@
|
|||||||
JAVASCRIPT
|
JAVASCRIPT
|
||||||
================================================================ -->
|
================================================================ -->
|
||||||
<script src="/pos/static/js/app-init.js"></script>
|
<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 src="/pos/static/js/pos.js"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
Reference in New Issue
Block a user