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>
259 lines
9.6 KiB
JavaScript
259 lines
9.6 KiB
JavaScript
// /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 = 2;
|
|
var QUEUE_STORE = 'sync_queue';
|
|
var INVENTORY_STORE = 'inventory_cache';
|
|
var TOP_PARTS_STORE = 'cached_parts';
|
|
|
|
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 });
|
|
}
|
|
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;
|
|
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); };
|
|
});
|
|
});
|
|
}
|
|
|
|
// ─── 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;
|
|
}
|
|
|
|
// ─── 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,
|
|
cacheTopParts: cacheTopParts,
|
|
searchCachedParts: searchCachedParts
|
|
};
|
|
|
|
})();
|