From c61e58ac6a33454b0e6ab8edeb1cff140eef4e9c Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Sat, 4 Apr 2026 08:03:28 +0000 Subject: [PATCH] =?UTF-8?q?feat(pos):=20add=205=20quick=20improvements=20?= =?UTF-8?q?=E2=80=94=20dark=20mode,=20email=20quotes,=20barcode=20scan,=20?= =?UTF-8?q?returns,=20offline=20catalog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- pos/app.py | 43 +++ pos/blueprints/pos_bp.py | 462 ++++++++++++++++++++++++++++++++ pos/config.py | 7 + pos/migrations/v1.5_returns.sql | 32 +++ pos/static/js/app-init.js | 24 +- pos/static/js/catalog.js | 15 ++ pos/static/js/native-bridge.js | 116 +++++++- pos/static/js/sync-engine.js | 72 ++++- pos/templates/catalog.html | 3 + 9 files changed, 751 insertions(+), 23 deletions(-) create mode 100644 pos/migrations/v1.5_returns.sql diff --git a/pos/app.py b/pos/app.py index 6112807..66bb16c 100644 --- a/pos/app.py +++ b/pos/app.py @@ -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__': diff --git a/pos/blueprints/pos_bp.py b/pos/blueprints/pos_bp.py index 3ef4ea6..0b616bf 100644 --- a/pos/blueprints/pos_bp.py +++ b/pos/blueprints/pos_bp.py @@ -595,6 +595,178 @@ def get_quotation(quot_id): return jsonify(quot) +@pos_bp.route('/quotations//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//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'{it[0]}{it[1]}{it[2]}' + f'${float(it[3]):,.2f}' + f'${float(it[6]):,.2f}' + ) + + html_body = f""" + +

Cotizacion #{q_id} - Nexus Autoparts

+

Cliente: {cust_name or 'Publico general'}

+

Vendedor: {emp_name or '-'}

+

Fecha: {created_at}

+

Vigencia: {valid_until or 'N/A'}

+ + + + + {items_html} +
No. ParteDescripcionCant.P. Unit.Subtotal
+

+ Subtotal: ${float(subtotal):,.2f}
+ IVA: ${float(tax_total):,.2f}
+ Total: ${float(total):,.2f} +

+ {f'

Notas: {notes}

' if notes else ''} +

Este es un documento informativo, no tiene validez fiscal.

+ + """ + + 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//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: } + """ + 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 diff --git a/pos/config.py b/pos/config.py index 82262b0..71c46aa 100644 --- a/pos/config.py +++ b/pos/config.py @@ -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", "") diff --git a/pos/migrations/v1.5_returns.sql b/pos/migrations/v1.5_returns.sql new file mode 100644 index 0000000..435c370 --- /dev/null +++ b/pos/migrations/v1.5_returns.sql @@ -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) diff --git a/pos/static/js/app-init.js b/pos/static/js/app-init.js index abad2f6..755d276 100644 --- a/pos/static/js/app-init.js +++ b/pos/static/js/app-init.js @@ -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() { diff --git a/pos/static/js/catalog.js b/pos/static/js/catalog.js index 059950d..c5dd697 100644 --- a/pos/static/js/catalog.js +++ b/pos/static/js/catalog.js @@ -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 ─── diff --git a/pos/static/js/native-bridge.js b/pos/static/js/native-bridge.js index 98c6a44..a22106e 100644 --- a/pos/static/js/native-bridge.js +++ b/pos/static/js/native-bridge.js @@ -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); diff --git a/pos/static/js/sync-engine.js b/pos/static/js/sync-engine.js index a0fa1a7..d3032f4 100644 --- a/pos/static/js/sync-engine.js +++ b/pos/static/js/sync-engine.js @@ -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 }; })(); diff --git a/pos/templates/catalog.html b/pos/templates/catalog.html index 1bcc034..827915f 100644 --- a/pos/templates/catalog.html +++ b/pos/templates/catalog.html @@ -595,6 +595,9 @@