diff --git a/pos/static/js/sync-engine.js b/pos/static/js/sync-engine.js new file mode 100644 index 0000000..a0fa1a7 --- /dev/null +++ b/pos/static/js/sync-engine.js @@ -0,0 +1,204 @@ +// /home/Autopartes/pos/static/js/sync-engine.js +// Nexus POS — Offline sync engine using IndexedDB + +(function () { + 'use strict'; + + var DB_NAME = 'nexus_pos_offline'; + var DB_VERSION = 1; + var QUEUE_STORE = 'sync_queue'; + var INVENTORY_STORE = 'inventory_cache'; + + var db = null; + + // ─── IndexedDB setup ────────────────────────────────────────── + function openDB() { + return new Promise(function (resolve, reject) { + if (db) { resolve(db); return; } + var req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = function (e) { + var d = e.target.result; + if (!d.objectStoreNames.contains(QUEUE_STORE)) { + d.createObjectStore(QUEUE_STORE, { keyPath: 'id', autoIncrement: true }); + } + if (!d.objectStoreNames.contains(INVENTORY_STORE)) { + var inv = d.createObjectStore(INVENTORY_STORE, { keyPath: 'item_id' }); + inv.createIndex('sku', 'sku', { unique: false }); + inv.createIndex('name', 'name', { unique: false }); + } + }; + req.onsuccess = function (e) { + db = e.target.result; + resolve(db); + }; + req.onerror = function () { reject(req.error); }; + }); + } + + // ─── Queue operations for offline replay ────────────────────── + function queueOperation(url, method, body) { + return openDB().then(function (d) { + return new Promise(function (resolve, reject) { + var tx = d.transaction(QUEUE_STORE, 'readwrite'); + tx.objectStore(QUEUE_STORE).add({ + url: url, + method: method, + body: body || null, + timestamp: Date.now() + }); + tx.oncomplete = function () { resolve(); }; + tx.onerror = function () { reject(tx.error); }; + }); + }); + } + + // ─── Process queued operations ──────────────────────────────── + function processQueue() { + return openDB().then(function (d) { + return new Promise(function (resolve, reject) { + var tx = d.transaction(QUEUE_STORE, 'readonly'); + var req = tx.objectStore(QUEUE_STORE).getAll(); + req.onsuccess = function () { resolve(req.result); }; + req.onerror = function () { reject(req.error); }; + }); + }).then(function (ops) { + if (!ops.length) return Promise.resolve({ synced: 0 }); + + // Replay in order + var chain = Promise.resolve(); + var synced = 0; + var failed = 0; + + ops.forEach(function (op) { + chain = chain.then(function () { + var opts = { method: op.method, headers: { 'Content-Type': 'application/json' } }; + if (op.body) opts.body = JSON.stringify(op.body); + + return fetch(op.url, opts).then(function (resp) { + if (resp.ok) { + synced++; + return removeFromQueue(op.id); + } + failed++; + }).catch(function () { + failed++; + }); + }); + }); + + return chain.then(function () { + return { synced: synced, failed: failed, total: ops.length }; + }); + }); + } + + function removeFromQueue(id) { + return openDB().then(function (d) { + return new Promise(function (resolve, reject) { + var tx = d.transaction(QUEUE_STORE, 'readwrite'); + tx.objectStore(QUEUE_STORE).delete(id); + tx.oncomplete = function () { resolve(); }; + tx.onerror = function () { reject(tx.error); }; + }); + }); + } + + function getQueueCount() { + return openDB().then(function (d) { + return new Promise(function (resolve, reject) { + var tx = d.transaction(QUEUE_STORE, 'readonly'); + var req = tx.objectStore(QUEUE_STORE).count(); + req.onsuccess = function () { resolve(req.result); }; + req.onerror = function () { reject(req.error); }; + }); + }); + } + + // ─── Inventory cache ────────────────────────────────────────── + function cacheInventory() { + return fetch('/pos/api/sync/inventory').then(function (resp) { + if (!resp.ok) throw new Error('Sync inventory failed: ' + resp.status); + return resp.json(); + }).then(function (data) { + var items = data.items || []; + return openDB().then(function (d) { + return new Promise(function (resolve, reject) { + var tx = d.transaction(INVENTORY_STORE, 'readwrite'); + var store = tx.objectStore(INVENTORY_STORE); + store.clear(); + items.forEach(function (item) { store.put(item); }); + tx.oncomplete = function () { resolve(items.length); }; + tx.onerror = function () { reject(tx.error); }; + }); + }); + }); + } + + function getCachedInventory(query) { + return openDB().then(function (d) { + return new Promise(function (resolve, reject) { + var tx = d.transaction(INVENTORY_STORE, 'readonly'); + var req = tx.objectStore(INVENTORY_STORE).getAll(); + req.onsuccess = function () { + var all = req.result; + if (!query) { resolve(all); return; } + + var q = query.toLowerCase(); + var filtered = all.filter(function (item) { + return (item.sku && item.sku.toLowerCase().indexOf(q) !== -1) || + (item.name && item.name.toLowerCase().indexOf(q) !== -1) || + (item.barcode && item.barcode.toLowerCase().indexOf(q) !== -1); + }); + resolve(filtered); + }; + req.onerror = function () { reject(req.error); }; + }); + }); + } + + // ─── Connectivity helpers ───────────────────────────────────── + function isOnline() { + return navigator.onLine; + } + + // ─── Auto-sync on reconnect ─────────────────────────────────── + window.addEventListener('online', function () { + console.log('[SyncEngine] Online — processing queue...'); + processQueue().then(function (result) { + if (result.synced > 0) { + console.log('[SyncEngine] Synced ' + result.synced + ' operations'); + } + }).catch(function (err) { + console.error('[SyncEngine] Queue processing error:', err); + }); + }); + + window.addEventListener('offline', function () { + console.log('[SyncEngine] Offline — operations will be queued'); + }); + + // Listen for SW sync messages + if ('serviceWorker' in navigator) { + navigator.serviceWorker.addEventListener('message', function (event) { + if (event.data && event.data.type === 'SYNC_REQUESTED') { + processQueue(); + } + }); + } + + // ─── Init DB on load ────────────────────────────────────────── + openDB().catch(function (err) { + console.error('[SyncEngine] Failed to open IndexedDB:', err); + }); + + // ─── Public API ─────────────────────────────────────────────── + window.SyncEngine = { + queueOperation: queueOperation, + processQueue: processQueue, + getQueueCount: getQueueCount, + isOnline: isOnline, + cacheInventory: cacheInventory, + getCachedInventory: getCachedInventory + }; + +})(); diff --git a/pos/static/pwa/icon-192.png b/pos/static/pwa/icon-192.png new file mode 100644 index 0000000..7f8b6bf Binary files /dev/null and b/pos/static/pwa/icon-192.png differ diff --git a/pos/static/pwa/icon-512.png b/pos/static/pwa/icon-512.png new file mode 100644 index 0000000..8dd9dd9 Binary files /dev/null and b/pos/static/pwa/icon-512.png differ diff --git a/pos/static/pwa/manifest.json b/pos/static/pwa/manifest.json new file mode 100644 index 0000000..faa8969 --- /dev/null +++ b/pos/static/pwa/manifest.json @@ -0,0 +1,13 @@ +{ + "name": "Nexus POS", + "short_name": "NexusPOS", + "description": "Sistema de Punto de Venta para Refaccionarias", + "start_url": "/pos/login", + "display": "standalone", + "background_color": "#0d0d0d", + "theme_color": "#F5A623", + "icons": [ + {"src": "/pos/static/pwa/icon-192.png", "sizes": "192x192", "type": "image/png"}, + {"src": "/pos/static/pwa/icon-512.png", "sizes": "512x512", "type": "image/png"} + ] +} diff --git a/pos/static/pwa/sw.js b/pos/static/pwa/sw.js new file mode 100644 index 0000000..43176d1 --- /dev/null +++ b/pos/static/pwa/sw.js @@ -0,0 +1,134 @@ +// /home/Autopartes/pos/static/pwa/sw.js +// Nexus POS — Service Worker v1 + +const CACHE_NAME = 'nexus-pos-v1'; + +const APP_SHELL = [ + '/pos/login', + '/pos/sale', + '/pos/catalog', + '/pos/inventory', + '/pos/customers', + '/pos/invoicing', + '/pos/accounting', + '/pos/dashboard', + '/pos/config', + '/pos/reports', + '/pos/static/css/tokens.css', + '/pos/static/css/common.css', + '/pos/static/js/app-init.js', + '/pos/static/js/sidebar.js', + '/pos/static/js/login.js', + '/pos/static/js/pos.js', + '/pos/static/js/catalog.js', + '/pos/static/js/inventory.js', + '/pos/static/js/customers.js', + '/pos/static/js/invoicing.js', + '/pos/static/js/accounting.js', + '/pos/static/js/dashboard.js', + '/pos/static/js/config.js', + '/pos/static/js/reports.js', + '/pos/static/js/offline-banner.js', + '/pos/static/js/sync-engine.js', + '/pos/static/pwa/manifest.json', + '/pos/static/pwa/icon-192.png', + '/pos/static/pwa/icon-512.png' +]; + +// ─── Install: pre-cache app shell ──────────────────────────────── +self.addEventListener('install', function (event) { + event.waitUntil( + caches.open(CACHE_NAME).then(function (cache) { + return cache.addAll(APP_SHELL); + }).then(function () { + return self.skipWaiting(); + }) + ); +}); + +// ─── Activate: purge old caches ────────────────────────────────── +self.addEventListener('activate', function (event) { + event.waitUntil( + caches.keys().then(function (names) { + return Promise.all( + names.filter(function (n) { return n !== CACHE_NAME; }) + .map(function (n) { return caches.delete(n); }) + ); + }).then(function () { + return self.clients.claim(); + }) + ); +}); + +// ─── Fetch strategy ────────────────────────────────────────────── +self.addEventListener('fetch', function (event) { + var url = new URL(event.request.url); + + // API calls → network-first + if (url.pathname.indexOf('/pos/api/') !== -1) { + event.respondWith(networkFirst(event.request)); + return; + } + + // Everything else → cache-first + event.respondWith(cacheFirst(event.request)); +}); + +function cacheFirst(request) { + return caches.match(request).then(function (cached) { + if (cached) { + // Update cache in background + fetch(request).then(function (response) { + if (response && response.status === 200) { + caches.open(CACHE_NAME).then(function (cache) { + cache.put(request, response); + }); + } + }).catch(function () { /* offline, ignore */ }); + return cached; + } + return fetch(request).then(function (response) { + if (response && response.status === 200) { + var clone = response.clone(); + caches.open(CACHE_NAME).then(function (cache) { + cache.put(request, clone); + }); + } + return response; + }); + }); +} + +function networkFirst(request) { + return fetch(request).then(function (response) { + if (response && response.status === 200) { + var clone = response.clone(); + caches.open(CACHE_NAME).then(function (cache) { + cache.put(request, clone); + }); + } + return response; + }).catch(function () { + return caches.match(request); + }); +} + +// ─── Background Sync ───────────────────────────────────────────── +self.addEventListener('sync', function (event) { + if (event.tag === 'nexus-pos-sync') { + event.respondWith && event.waitUntil( + self.clients.matchAll().then(function (clients) { + clients.forEach(function (client) { + client.postMessage({ type: 'SYNC_REQUESTED' }); + }); + }) + ); + } +}); + +// ─── Message handler ───────────────────────────────────────────── +self.addEventListener('message', function (event) { + if (event.data && event.data.type === 'SKIP_WAITING') { + self.skipWaiting(); + } +}); diff --git a/pos/templates/accounting.html b/pos/templates/accounting.html index a41d1c7..2548828 100644 --- a/pos/templates/accounting.html +++ b/pos/templates/accounting.html @@ -6,6 +6,8 @@