From 32581739ade20168f75a0cb7bb860a82a1a64f80 Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Thu, 2 Apr 2026 07:18:01 +0000 Subject: [PATCH] feat(pos): PWA con Service Worker + sync offline - Add manifest.json, sw.js with cache-first/network-first strategies - Add sync-engine.js with IndexedDB queue for offline operations - Add /pos/api/sync/inventory endpoint for offline inventory cache - Add /pos/sw.js route for proper SW scope registration - Generate PWA icons (192x192, 512x512) - Update all 10 HTML templates with manifest link, theme-color meta, SW registration, and sync-engine script Co-Authored-By: Claude Opus 4.6 (1M context) --- pos/static/js/sync-engine.js | 204 ++++++++++++++++++++++++++++++++++ pos/static/pwa/icon-192.png | Bin 0 -> 1732 bytes pos/static/pwa/icon-512.png | Bin 0 -> 5257 bytes pos/static/pwa/manifest.json | 13 +++ pos/static/pwa/sw.js | 134 ++++++++++++++++++++++ pos/templates/accounting.html | 4 + pos/templates/catalog.html | 6 + pos/templates/config.html | 4 + pos/templates/customers.html | 4 + pos/templates/dashboard.html | 4 + pos/templates/inventory.html | 4 + pos/templates/invoicing.html | 4 + pos/templates/pos.html | 6 + pos/templates/reports.html | 4 + 14 files changed, 391 insertions(+) create mode 100644 pos/static/js/sync-engine.js create mode 100644 pos/static/pwa/icon-192.png create mode 100644 pos/static/pwa/icon-512.png create mode 100644 pos/static/pwa/manifest.json create mode 100644 pos/static/pwa/sw.js 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 0000000000000000000000000000000000000000..7f8b6bfcbfc61198bb9365644cc47f4dc150801d GIT binary patch literal 1732 zcmd5-`8(8m6#jgtp-D008d;ubFi0kzX6!uD7&480$;`vGFD*2#rHh$rN~J7~OeRLS zEmI;(2yHHsbtJN6ZIpQEVr&_*+_}HsKcFAZIX|5Doaa36d!BP{xH=z`l~I)e0LYRZ zNbZtY|DPeGCEk4`VkZF7sbtbo&-lBuqdwQYLecdLaRxD^>XFBBkBL<{eG0z&IFV46 zppu?)Ke;g_4gUm1pMOMCp^{=Rq4bC!4(Az>Qrk{9Dj(D*$iEZx=)^3KOpYXqw24d0 zvmG@q-?tf`li6?9D$NLt&MWvFM<>80#y6d{~*@InLKPQJwpQO@gYQagSnQH zNVt_Y5*w_Muxa}*27eIF6run>o+*QJE7 z-a^qG;Bt_7;@VNSoD0DtKMsqKaN&b@CFik?YDlztGQ0FOR&#YMvs^O2EF_K?7gROf zXR07%U?LhNhNQ0A(&OlgVoB|O#K)?I%fKOs8s(;gThif2?B78I{es-vnUh8}pH1>v z+BqbLd|orUJRRV8mezVZaueVtf2^tv{iF%dZ08^6CeeC7|9h zuiK?blNW=S%Zg6;g~SuOnll50#hP;V5o7HfxJNxjqVd~NMcQznwG{7T zU;-0QA^2$mcFEbUXu_3S*GVE(#l*~Y%5rObto2Vfv#(@3H6K5*Iiq{NVg1R$sciVc>lq9=ApKI-)>vnqU&c86%u-#c< z^wk6=BH#1m^!Z4r#4;xpch#l>X4Txs*ZbB-nQ_t0t+i>&P3R~ENL=VCpD;GwBXH-2 z`Pu>NVNvMO3uZxe7Upxj?6fyLzDf;>)m8MIVAyW{EN$CjS`}6vFg##4c)niJXF{lx zV{v)gbW$2t-Zb4K_%e8ESEc2&GDKY*tn*tFGnV?=0uQ<65O{h*G(@dpem(WXoXS(_ zZ)B75G(l>exhMCK+zH+BeGs+!{%n<};ByAc79KFT<*&cEH8n{gL|p?g;nhysbX>W` zt{6B99NQ{vwJey3+;6K5LtMXeL_qFun86BAHa4*ZMeW~*B`gF=Y9^kuqoXxD*jF}4 z0E#YhFp=CkKmt2+C*Sj z?Os#GoI)f7_Z*(P%v`Npo59Z50?4cQhE>f%)ok#_yu1x7a=Y5wVl}tS`O5|ZBjggK ztn2_Zu;xKN_s?Pfyr#dkSkl7SxZkGifowq6ctyl|3SY$#Ws^i5Y5^Ui`v+il#7CqC5_8+MhNj<6Dlpb0-wBZIoJpm` HfXn{^m50)Q literal 0 HcmV?d00001 diff --git a/pos/static/pwa/icon-512.png b/pos/static/pwa/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..8dd9dd929df5836827a9161aac5ac3e55b72a7aa GIT binary patch literal 5257 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 literal 0 HcmV?d00001 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 @@ Contabilidad — Nexus Autoparts POS + +