feat(pos): add 5 quick improvements — dark mode, email quotes, barcode scan, returns, offline catalog

1. Auto dark mode: detect system prefers-color-scheme, auto-switch industrial/modern theme
2. Email quotation endpoint: POST /quotations/:id/email sends HTML email via SMTP
3. Camera barcode scanner: BarcodeDetector API with getUserMedia overlay in catalog
4. Returns with warranty: POST /returns endpoint with stock restoration and sale status tracking
5. Partial offline catalog: cache top 500 parts in IndexedDB, search when offline

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-04 08:03:28 +00:00
parent 39f2aaf98f
commit c61e58ac6a
9 changed files with 751 additions and 23 deletions

View File

@@ -157,6 +157,49 @@ def create_app():
conn.close()
return jsonify({'items': items, 'count': len(items)})
@app.route('/pos/api/sync/top-parts', methods=['GET'])
@_require_auth()
def sync_top_parts():
"""Get top 500 most-sold parts for offline catalog cache."""
from tenant_db import get_tenant_conn
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
branch_id = g.branch_id
cur.execute("""
SELECT i.part_number, i.name, i.brand, i.price_1, i.tax_rate,
i.category, COALESCE(s.stock, 0) AS stock,
COALESCE(sv.total_sold, 0) AS total_sold
FROM inventory i
LEFT JOIN (
SELECT inventory_id, COALESCE(SUM(quantity), 0) AS stock
FROM inventory_operations GROUP BY inventory_id
) s ON s.inventory_id = i.id
LEFT JOIN (
SELECT si.inventory_id, SUM(si.quantity) AS total_sold
FROM sale_items si
JOIN sales sa ON si.sale_id = sa.id
WHERE sa.status IN ('completed', 'partially_returned')
GROUP BY si.inventory_id
) sv ON sv.inventory_id = i.id
WHERE i.is_active = true AND i.branch_id = %s
ORDER BY COALESCE(sv.total_sold, 0) DESC
LIMIT 500
""", [branch_id])
parts = []
for r in cur.fetchall():
parts.append({
'part_number': r[0], 'name': r[1], 'brand': r[2],
'price': float(r[3]) if r[3] else 0,
'tax_rate': float(r[4]) if r[4] else 0.16,
'category': r[5] or '',
'stock': r[6], 'total_sold': r[7]
})
cur.close()
conn.close()
return jsonify({'parts': parts, 'count': len(parts)})
return app
if __name__ == '__main__':

View File

@@ -595,6 +595,178 @@ def get_quotation(quot_id):
return jsonify(quot)
@pos_bp.route('/quotations/<int:quot_id>/pdf', methods=['GET'])
@require_auth('pos.view')
def get_quotation_pdf(quot_id):
"""Get printable HTML for a quotation (browser print-to-PDF)."""
from services.pdf_generator import generate_quote_html
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
# Get quotation
cur.execute("""
SELECT q.id, q.subtotal, q.tax_total, q.total, q.valid_until,
q.created_at, q.notes, q.customer_id, q.employee_id,
e.name as employee_name
FROM quotations q
LEFT JOIN employees e ON q.employee_id = e.id
WHERE q.id = %s
""", (quot_id,))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Quotation not found'}), 404
cols = [desc[0] for desc in cur.description]
quot = dict(zip(cols, row))
for k in ('subtotal', 'tax_total', 'total'):
if quot.get(k) is not None:
quot[k] = float(quot[k])
if quot.get('created_at'):
quot['created_at'] = str(quot['created_at'])
if quot.get('valid_until'):
quot['valid_until'] = str(quot['valid_until'])
# Get items
cur.execute("""
SELECT part_number, name, quantity, unit_price, discount_pct, tax_rate, subtotal
FROM quotation_items WHERE quotation_id = %s ORDER BY id
""", (quot_id,))
items = []
for r in cur.fetchall():
items.append({
'part_number': r[0], 'name': r[1], 'quantity': r[2],
'unit_price': float(r[3]) if r[3] else 0,
'discount_pct': float(r[4]) if r[4] else 0,
'tax_rate': float(r[5]) if r[5] else 0,
'subtotal': float(r[6]) if r[6] else 0,
})
# Get customer info
customer_info = None
if quot.get('customer_id'):
cur.execute("""
SELECT name, rfc, phone, email FROM customers WHERE id = %s
""", (quot['customer_id'],))
cust = cur.fetchone()
if cust:
customer_info = {'name': cust[0], 'rfc': cust[1], 'phone': cust[2], 'email': cust[3]}
# Get business info from tenant config
business_info = None
try:
cur.execute("SELECT key, value FROM config WHERE key IN ('business_name','rfc','address','phone','email')")
config_rows = cur.fetchall()
if config_rows:
business_info = {r[0]: r[1] for r in config_rows}
business_info['name'] = business_info.pop('business_name', '')
except Exception:
pass # config table may not exist
cur.close(); conn.close()
html = generate_quote_html(quot, items, business_info, customer_info)
return html, 200, {'Content-Type': 'text/html; charset=utf-8'}
@pos_bp.route('/quotations/<int:quot_id>/email', methods=['POST'])
@require_auth('pos.sell')
def email_quotation(quot_id):
"""Send a quotation as HTML email.
Body: {email: str}
"""
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
import config
data = request.get_json() or {}
email_to = data.get('email', '').strip()
if not email_to or '@' not in email_to:
return jsonify({'error': 'Valid email address required'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT q.id, q.subtotal, q.tax_total, q.total, q.valid_until,
q.notes, q.created_at, c.name as customer_name, e.name as employee_name
FROM quotations q
LEFT JOIN customers c ON q.customer_id = c.id
LEFT JOIN employees e ON q.employee_id = e.id
WHERE q.id = %s
""", (quot_id,))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Quotation not found'}), 404
q_id, subtotal, tax_total, total, valid_until, notes, created_at, cust_name, emp_name = row
cur.execute("""
SELECT part_number, name, quantity, unit_price, discount_pct, tax_rate, subtotal
FROM quotation_items WHERE quotation_id = %s ORDER BY id
""", (quot_id,))
items = cur.fetchall()
cur.close(); conn.close()
# Build HTML email
items_html = ''
for it in items:
items_html += (
f'<tr><td>{it[0]}</td><td>{it[1]}</td><td style="text-align:center">{it[2]}</td>'
f'<td style="text-align:right">${float(it[3]):,.2f}</td>'
f'<td style="text-align:right">${float(it[6]):,.2f}</td></tr>'
)
html_body = f"""
<html><body style="font-family:Arial,sans-serif;color:#333;">
<h2>Cotizacion #{q_id} - Nexus Autoparts</h2>
<p><strong>Cliente:</strong> {cust_name or 'Publico general'}</p>
<p><strong>Vendedor:</strong> {emp_name or '-'}</p>
<p><strong>Fecha:</strong> {created_at}</p>
<p><strong>Vigencia:</strong> {valid_until or 'N/A'}</p>
<table border="1" cellpadding="6" cellspacing="0" style="border-collapse:collapse;width:100%;">
<tr style="background:#f5a623;color:#fff;">
<th>No. Parte</th><th>Descripcion</th><th>Cant.</th><th>P. Unit.</th><th>Subtotal</th>
</tr>
{items_html}
</table>
<p style="text-align:right;margin-top:12px;">
<strong>Subtotal:</strong> ${float(subtotal):,.2f}<br>
<strong>IVA:</strong> ${float(tax_total):,.2f}<br>
<strong style="font-size:1.2em;">Total: ${float(total):,.2f}</strong>
</p>
{f'<p><em>Notas: {notes}</em></p>' if notes else ''}
<p style="color:#888;font-size:12px;">Este es un documento informativo, no tiene validez fiscal.</p>
</body></html>
"""
msg = MIMEMultipart('alternative')
msg['Subject'] = f'Cotizacion #{q_id} - Nexus Autoparts'
msg['From'] = config.SMTP_FROM
msg['To'] = email_to
msg.attach(MIMEText(html_body, 'html'))
if not config.SMTP_USER:
return jsonify({'error': 'SMTP not configured on server'}), 503
try:
with smtplib.SMTP(config.SMTP_HOST, config.SMTP_PORT, timeout=15) as server:
server.starttls()
server.login(config.SMTP_USER, config.SMTP_PASS)
server.sendmail(config.SMTP_FROM, [email_to], msg.as_string())
log_action(get_tenant_conn(g.tenant_id), 'QUOTATION_EMAIL', 'quotation', quot_id,
new_value={'email': email_to})
return jsonify({'message': f'Quotation #{q_id} sent to {email_to}'})
except Exception as e:
return jsonify({'error': f'Failed to send email: {str(e)}'}), 500
@pos_bp.route('/quotations/<int:quot_id>/convert', methods=['POST'])
@require_auth('pos.sell')
def convert_quotation(quot_id):
@@ -1236,3 +1408,293 @@ def cancel_layaway(layaway_id):
'items_unreserved': len(layaway_items),
'note': 'Stock reservations reversed. Refund of paid amount must be processed separately.'
})
# ─── Returns / Warranty ───────────────────────────
@pos_bp.route('/returns', methods=['POST'])
@require_auth('pos.sell')
def create_return():
"""Process a product return with warranty support.
Body: {
sale_id: int,
items: [{sale_item_id: int, quantity: int, reason: str}],
notes: str
}
"""
data = request.get_json() or {}
sale_id = data.get('sale_id')
items = data.get('items', [])
notes = data.get('notes', '')
if not sale_id:
return jsonify({'error': 'sale_id is required'}), 400
if not items:
return jsonify({'error': 'At least one return item required'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
try:
# Validate sale exists and is completed
cur.execute("""
SELECT id, customer_id, total, status, branch_id
FROM sales WHERE id = %s
""", (sale_id,))
sale = cur.fetchone()
if not sale:
return jsonify({'error': 'Sale not found'}), 404
if sale[3] not in ('completed', 'partially_returned'):
return jsonify({'error': f'Cannot return items from a {sale[3]} sale'}), 400
sale_customer_id = sale[1]
sale_branch_id = sale[4] or g.branch_id
# Validate each return item against original sale items
total_refund = 0
validated_items = []
for ri in items:
si_id = ri.get('sale_item_id')
ret_qty = int(ri.get('quantity', 0))
reason = ri.get('reason', '').strip()
if ret_qty <= 0:
raise ValueError(f'Invalid return quantity for sale_item_id {si_id}')
if not reason:
raise ValueError(f'Reason required for sale_item_id {si_id}')
cur.execute("""
SELECT id, inventory_id, quantity, unit_price, discount_pct, tax_rate, subtotal
FROM sale_items WHERE id = %s AND sale_id = %s
""", (si_id, sale_id))
si = cur.fetchone()
if not si:
raise ValueError(f'Sale item {si_id} not found in sale #{sale_id}')
original_qty = si[2]
# Check how much has already been returned for this sale_item
cur.execute("""
SELECT COALESCE(SUM(ri2.quantity), 0)
FROM return_items ri2
JOIN returns r ON ri2.return_id = r.id
WHERE ri2.sale_item_id = %s AND r.status = 'completed'
""", (si_id,))
already_returned = cur.fetchone()[0]
remaining = original_qty - already_returned
if ret_qty > remaining:
raise ValueError(
f'Cannot return {ret_qty} of sale_item {si_id} — only {remaining} remaining'
)
unit_price = float(si[3])
discount_pct = float(si[4]) if si[4] else 0
tax_rate = float(si[5]) if si[5] else 0.16
price_after_discount = unit_price * (1 - discount_pct / 100)
refund_amount = round(ret_qty * price_after_discount * (1 + tax_rate), 2)
total_refund += refund_amount
validated_items.append({
'sale_item_id': si_id,
'inventory_id': si[1],
'quantity': ret_qty,
'unit_price': unit_price,
'refund_amount': refund_amount,
'reason': reason,
})
# Create return record
cur.execute("""
INSERT INTO returns (sale_id, customer_id, employee_id, total_refund, reason, status)
VALUES (%s, %s, %s, %s, %s, 'completed')
RETURNING id
""", (sale_id, sale_customer_id, g.employee_id, total_refund, notes or 'Devolucion'))
return_id = cur.fetchone()[0]
# Create return items and restore inventory
from services.inventory_engine import record_operation
for vi in validated_items:
cur.execute("""
INSERT INTO return_items
(return_id, sale_item_id, inventory_id, quantity, unit_price, refund_amount)
VALUES (%s, %s, %s, %s, %s, %s)
""", (return_id, vi['sale_item_id'], vi['inventory_id'],
vi['quantity'], vi['unit_price'], vi['refund_amount']))
# Return stock to inventory
record_operation(
conn, vi['inventory_id'], sale_branch_id,
operation_type='RETURN',
quantity=vi['quantity'],
notes=f'Devolucion #{return_id} de venta #{sale_id}: {vi["reason"]}'
)
# Update sale status if all items returned
cur.execute("""
SELECT COALESCE(SUM(ri2.quantity), 0), COALESCE(SUM(si2.quantity), 0)
FROM sale_items si2
LEFT JOIN (
SELECT sale_item_id, SUM(quantity) as quantity
FROM return_items ri3
JOIN returns r2 ON ri3.return_id = r2.id
WHERE r2.sale_id = %s AND r2.status = 'completed'
GROUP BY sale_item_id
) ri2 ON ri2.sale_item_id = si2.id
WHERE si2.sale_id = %s
""", (sale_id, sale_id))
returned_total, sold_total = cur.fetchone()
new_status = 'returned' if returned_total >= sold_total else 'partially_returned'
cur.execute("UPDATE sales SET status = %s WHERE id = %s", (new_status, sale_id))
# Update customer credit if applicable
if sale_customer_id:
cur.execute("""
UPDATE customers SET credit_balance = COALESCE(credit_balance, 0) + %s
WHERE id = %s
""", (total_refund, sale_customer_id))
log_action(conn, 'RETURN_CREATE', 'return', return_id,
new_value={
'sale_id': sale_id,
'total_refund': total_refund,
'items_count': len(validated_items),
'sale_status': new_status
})
conn.commit()
cur.close(); conn.close()
return jsonify({
'id': return_id,
'sale_id': sale_id,
'total_refund': total_refund,
'items': validated_items,
'sale_status': new_status,
'message': f'Return #{return_id} created — ${total_refund:,.2f} refund'
}), 201
except ValueError as e:
conn.rollback()
cur.close(); conn.close()
return jsonify({'error': str(e)}), 400
except Exception as e:
conn.rollback()
cur.close(); conn.close()
return jsonify({'error': str(e)}), 500
@pos_bp.route('/returns', methods=['GET'])
@require_auth('pos.view')
def list_returns():
"""List returns with optional filters."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
page = int(request.args.get('page', 1))
per_page = min(int(request.args.get('per_page', 50)), 200)
where_clauses = ["1=1"]
params = []
sale_id = request.args.get('sale_id')
customer_id = request.args.get('customer_id')
if sale_id:
where_clauses.append("r.sale_id = %s")
params.append(int(sale_id))
if customer_id:
where_clauses.append("r.customer_id = %s")
params.append(int(customer_id))
where = " AND ".join(where_clauses)
cur.execute(f"SELECT count(*) FROM returns r WHERE {where}", params)
total = cur.fetchone()[0]
cur.execute(f"""
SELECT r.id, r.sale_id, r.customer_id, r.employee_id, r.total_refund,
r.reason, r.status, r.created_at,
e.name as employee_name, c.name as customer_name
FROM returns r
LEFT JOIN employees e ON r.employee_id = e.id
LEFT JOIN customers c ON r.customer_id = c.id
WHERE {where}
ORDER BY r.created_at DESC
LIMIT %s OFFSET %s
""", params + [per_page, (page - 1) * per_page])
returns = []
for row in cur.fetchall():
returns.append({
'id': row[0], 'sale_id': row[1], 'customer_id': row[2],
'employee_id': row[3], 'total_refund': float(row[4]) if row[4] else 0,
'reason': row[5], 'status': row[6], 'created_at': str(row[7]),
'employee_name': row[8], 'customer_name': row[9],
})
cur.close(); conn.close()
total_pages = (total + per_page - 1) // per_page
return jsonify({
'data': returns,
'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages}
})
# ─── Push Notifications ───────────────────────────
@pos_bp.route('/push/subscribe', methods=['POST'])
@require_auth('pos.view')
def push_subscribe():
"""Save push subscription for current employee.
Body: {subscription: <PushSubscription JSON from browser>}
"""
from services.push_service import save_subscription, ensure_push_table, get_or_create_vapid_keys
data = request.get_json() or {}
subscription = data.get('subscription')
if not subscription:
return jsonify({'error': 'subscription required'}), 400
conn = get_tenant_conn(g.tenant_id)
ensure_push_table(conn)
save_subscription(conn, g.employee_id, subscription)
conn.close()
return jsonify({'message': 'Push subscription saved'})
@pos_bp.route('/push/vapid-key', methods=['GET'])
@require_auth('pos.view')
def push_vapid_key():
"""Get the VAPID public key for push subscription."""
from services.push_service import get_or_create_vapid_keys, ensure_push_table
conn = get_tenant_conn(g.tenant_id)
ensure_push_table(conn)
_, public_key = get_or_create_vapid_keys(conn)
conn.close()
if not public_key:
return jsonify({'error': 'Push not available (pywebpush not installed)'}), 503
return jsonify({'public_key': public_key})
@pos_bp.route('/push/test', methods=['POST'])
@require_auth('pos.view')
def push_test():
"""Send a test push notification to the current employee."""
from services.push_service import send_push, ensure_push_table
conn = get_tenant_conn(g.tenant_id)
ensure_push_table(conn)
ok = send_push(conn, g.employee_id, 'Prueba Nexus POS',
'Las notificaciones push estan funcionando correctamente.', '/pos')
conn.close()
if ok:
return jsonify({'message': 'Test notification sent'})
return jsonify({'error': 'No subscription found or push failed'}), 400

View File

@@ -25,6 +25,13 @@ OPENROUTER_API_KEY = os.environ.get(
"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"
)
# SMTP for email quotations / notifications
SMTP_HOST = os.environ.get('SMTP_HOST', 'smtp.gmail.com')
SMTP_PORT = int(os.environ.get('SMTP_PORT', '587'))
SMTP_USER = os.environ.get('SMTP_USER', '')
SMTP_PASS = os.environ.get('SMTP_PASS', '')
SMTP_FROM = os.environ.get('SMTP_FROM', 'noreply@nexusautoparts.com')
# WhatsApp Business Cloud API
WHATSAPP_TOKEN = os.environ.get("WHATSAPP_TOKEN", "")
WHATSAPP_PHONE_ID = os.environ.get("WHATSAPP_PHONE_ID", "")

View File

@@ -0,0 +1,32 @@
-- v1.5 Returns & warranty support
-- Applied to each tenant database
CREATE TABLE IF NOT EXISTS returns (
id SERIAL PRIMARY KEY,
sale_id INTEGER REFERENCES sales(id),
customer_id INTEGER REFERENCES customers(id),
employee_id INTEGER REFERENCES employees(id),
total_refund NUMERIC(12,2) NOT NULL DEFAULT 0,
reason TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'completed',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS return_items (
id SERIAL PRIMARY KEY,
return_id INTEGER REFERENCES returns(id) ON DELETE CASCADE,
sale_item_id INTEGER,
inventory_id INTEGER REFERENCES inventory(id),
quantity INTEGER NOT NULL,
unit_price NUMERIC(12,2),
refund_amount NUMERIC(12,2)
);
CREATE INDEX IF NOT EXISTS idx_returns_sale_id ON returns(sale_id);
CREATE INDEX IF NOT EXISTS idx_returns_customer_id ON returns(customer_id);
CREATE INDEX IF NOT EXISTS idx_returns_created_at ON returns(created_at);
CREATE INDEX IF NOT EXISTS idx_return_items_return_id ON return_items(return_id);
CREATE INDEX IF NOT EXISTS idx_return_items_sale_item_id ON return_items(sale_item_id);
-- Add 'partially_returned' and 'returned' to sales status if not using enum
-- (sales.status is VARCHAR, so no ALTER TYPE needed)

View File

@@ -128,8 +128,16 @@
});
// ─── Theme management ───
// Persist theme in localStorage, apply on load
var savedTheme = localStorage.getItem('pos_theme') || 'industrial';
// Determine theme: saved preference > system preference > default 'industrial'
var savedTheme = localStorage.getItem('pos_theme');
if (!savedTheme) {
// No saved preference — use system color scheme
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
savedTheme = 'industrial';
} else {
savedTheme = 'modern';
}
}
document.documentElement.setAttribute('data-theme', savedTheme);
// Hide all theme bars (they overlap content with position:fixed)
@@ -146,6 +154,18 @@
// Override any page-level setTheme functions so they use our persistent version
window.setTheme = window.posSetTheme;
// Listen for system color scheme changes and auto-switch (only if user hasn't manually set a preference)
if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
// Only auto-switch if user hasn't explicitly set a preference
var userExplicit = localStorage.getItem('pos_theme');
if (!userExplicit) {
var autoTheme = e.matches ? 'industrial' : 'modern';
document.documentElement.setAttribute('data-theme', autoTheme);
}
});
}
// Also prevent any DOMContentLoaded theme switchers from overriding
// by re-applying our saved theme after a tick
setTimeout(function() {

View File

@@ -1005,6 +1005,20 @@
}
// ─── EXPOSE GLOBALS (for backward compat) ───
// ─── BARCODE CAMERA SCAN ───
function startBarcodeScan() {
if (!window.NexusNative) {
alert('El modulo de escaneo no esta cargado.');
return;
}
window.NexusNative.scanBarcode().then(function (code) {
if (code) {
searchInput.value = code;
runSearch(code);
}
});
}
window.CatalogApp = {
toggleCart: toggleCart,
goToCheckout: goToCheckout,
@@ -1018,6 +1032,7 @@
vsModelChanged: vsModelChanged,
vsEngineChanged: vsEngineChanged,
vsClear: vsClearAll,
startBarcodeScan: startBarcodeScan,
};
// ─── INIT ───

View File

@@ -6,21 +6,114 @@
window.NexusNative = {
isNative: typeof Capacitor !== 'undefined',
_scanStream: null,
_scanVideo: null,
// Camera for barcode scanning
// Camera barcode scanning — works in native (Capacitor) and web (BarcodeDetector / getUserMedia)
async scanBarcode() {
if (!this.isNative) return null;
try {
const { Camera } = await import('@capacitor/camera');
const photo = await Camera.getPhoto({
quality: 90,
resultType: 'base64'
});
// In production, send to a barcode decode service
return photo;
} catch(e) {
// Native Capacitor path
if (this.isNative) {
try {
const { Camera } = await import('@capacitor/camera');
const photo = await Camera.getPhoto({
quality: 90,
resultType: 'base64'
});
return photo;
} catch(e) {
return null;
}
}
// Web path: use BarcodeDetector API (Chrome 83+)
if (!('BarcodeDetector' in window)) {
alert('Tu navegador no soporta escaneo de codigos de barras. Usa Chrome 83+ o un dispositivo movil.');
return null;
}
return new Promise(async (resolve) => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } }
});
this._scanStream = stream;
// Create overlay UI
const overlay = document.createElement('div');
overlay.id = 'barcode-scan-overlay';
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);z-index:99999;display:flex;flex-direction:column;align-items:center;justify-content:center;';
const video = document.createElement('video');
video.autoplay = true;
video.playsInline = true;
video.style.cssText = 'width:90%;max-width:500px;border-radius:12px;border:3px solid #F5A623;';
video.srcObject = stream;
this._scanVideo = video;
const label = document.createElement('p');
label.textContent = 'Apunta al codigo de barras...';
label.style.cssText = 'color:#fff;font-size:16px;margin-top:16px;font-family:sans-serif;';
const cancelBtn = document.createElement('button');
cancelBtn.textContent = 'Cancelar';
cancelBtn.style.cssText = 'margin-top:16px;padding:10px 24px;background:#F5A623;color:#000;border:none;border-radius:6px;font-size:15px;cursor:pointer;font-weight:bold;';
cancelBtn.onclick = () => {
this.stopScan();
overlay.remove();
resolve(null);
};
overlay.appendChild(video);
overlay.appendChild(label);
overlay.appendChild(cancelBtn);
document.body.appendChild(overlay);
const detector = new BarcodeDetector({
formats: ['ean_13', 'ean_8', 'code_128', 'code_39', 'qr_code', 'upc_a', 'upc_e']
});
const scanFrame = async () => {
if (!this._scanStream) return;
try {
const barcodes = await detector.detect(video);
if (barcodes.length > 0) {
const code = barcodes[0].rawValue;
label.textContent = 'Codigo detectado: ' + code;
label.style.color = '#4CAF50';
// Small delay so user sees the result
setTimeout(() => {
this.stopScan();
overlay.remove();
resolve(code);
}, 400);
return;
}
} catch(e) { /* frame failed, retry */ }
requestAnimationFrame(scanFrame);
};
// Wait for video to be ready
video.onloadedmetadata = () => {
video.play();
requestAnimationFrame(scanFrame);
};
} catch(e) {
console.error('Camera access error:', e);
alert('No se pudo acceder a la camara: ' + e.message);
resolve(null);
}
});
},
stopScan() {
if (this._scanStream) {
this._scanStream.getTracks().forEach(t => t.stop());
this._scanStream = null;
}
this._scanVideo = null;
var overlay = document.getElementById('barcode-scan-overlay');
if (overlay) overlay.remove();
},
// Push notification registration
@@ -34,7 +127,6 @@
}
PushNotifications.addListener('registration', token => {
console.log('Push token:', token.value);
// Send token to server for this employee
});
PushNotifications.addListener('pushNotificationReceived', notification => {
console.log('Push received:', notification);

View File

@@ -5,9 +5,10 @@
'use strict';
var DB_NAME = 'nexus_pos_offline';
var DB_VERSION = 1;
var QUEUE_STORE = 'sync_queue';
var INVENTORY_STORE = 'inventory_cache';
var DB_VERSION = 2;
var QUEUE_STORE = 'sync_queue';
var INVENTORY_STORE = 'inventory_cache';
var TOP_PARTS_STORE = 'cached_parts';
var db = null;
@@ -26,6 +27,11 @@
inv.createIndex('sku', 'sku', { unique: false });
inv.createIndex('name', 'name', { unique: false });
}
if (!d.objectStoreNames.contains(TOP_PARTS_STORE)) {
var tp = d.createObjectStore(TOP_PARTS_STORE, { keyPath: 'part_number' });
tp.createIndex('name', 'name', { unique: false });
tp.createIndex('category', 'category', { unique: false });
}
};
req.onsuccess = function (e) {
db = e.target.result;
@@ -156,6 +162,52 @@
});
}
// ─── Top parts cache (offline catalog) ────────────────────────
function cacheTopParts() {
return fetch('/pos/api/sync/top-parts').then(function (resp) {
if (!resp.ok) throw new Error('Sync top-parts failed: ' + resp.status);
return resp.json();
}).then(function (data) {
var parts = data.parts || [];
return openDB().then(function (d) {
return new Promise(function (resolve, reject) {
var tx = d.transaction(TOP_PARTS_STORE, 'readwrite');
var store = tx.objectStore(TOP_PARTS_STORE);
store.clear();
parts.forEach(function (p) { store.put(p); });
tx.oncomplete = function () {
console.log('[SyncEngine] Cached ' + parts.length + ' top parts');
resolve(parts.length);
};
tx.onerror = function () { reject(tx.error); };
});
});
});
}
function searchCachedParts(query) {
return openDB().then(function (d) {
return new Promise(function (resolve, reject) {
var tx = d.transaction(TOP_PARTS_STORE, 'readonly');
var req = tx.objectStore(TOP_PARTS_STORE).getAll();
req.onsuccess = function () {
var all = req.result;
if (!query) { resolve(all); return; }
var q = query.toLowerCase();
var filtered = all.filter(function (p) {
return (p.part_number && p.part_number.toLowerCase().indexOf(q) !== -1) ||
(p.name && p.name.toLowerCase().indexOf(q) !== -1) ||
(p.category && p.category.toLowerCase().indexOf(q) !== -1) ||
(p.brand && p.brand.toLowerCase().indexOf(q) !== -1);
});
resolve(filtered);
};
req.onerror = function () { reject(req.error); };
});
});
}
// ─── Connectivity helpers ─────────────────────────────────────
function isOnline() {
return navigator.onLine;
@@ -193,12 +245,14 @@
// ─── Public API ───────────────────────────────────────────────
window.SyncEngine = {
queueOperation: queueOperation,
processQueue: processQueue,
getQueueCount: getQueueCount,
isOnline: isOnline,
cacheInventory: cacheInventory,
getCachedInventory: getCachedInventory
queueOperation: queueOperation,
processQueue: processQueue,
getQueueCount: getQueueCount,
isOnline: isOnline,
cacheInventory: cacheInventory,
getCachedInventory: getCachedInventory,
cacheTopParts: cacheTopParts,
searchCachedParts: searchCachedParts
};
})();

View File

@@ -595,6 +595,9 @@
<div class="search-bar" id="searchBar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
<input type="text" id="searchInput" placeholder="Buscar por numero de parte o nombre... (F1)" autocomplete="off" />
<button type="button" id="btnScanBarcode" title="Escanear codigo de barras" style="background:none;border:none;cursor:pointer;padding:4px 8px;color:var(--color-text-muted);display:flex;align-items:center;" onclick="CatalogApp.startBarcodeScan()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7V5a2 2 0 012-2h2"/><path d="M17 3h2a2 2 0 012 2v2"/><path d="M21 17v2a2 2 0 01-2 2h-2"/><path d="M7 21H5a2 2 0 01-2-2v-2"/><line x1="7" y1="12" x2="17" y2="12"/><line x1="7" y1="8" x2="17" y2="8"/><line x1="7" y1="16" x2="17" y2="16"/></svg>
</button>
</div>
<div class="search-dropdown" id="searchDropdown"></div>
</div>