feat(ui): helpers pos-utils.js (barcode feedback, saved filters, resizable columns, density/touch toggles, notifications dropdown, ticket preview, image comparator, infinite scroll, sparklines) + inventory.html modals + pos-ui.css timeline & kanban
This commit is contained in:
@@ -5,6 +5,40 @@
|
||||
* Load AFTER tokens.css and common.css, BEFORE page-specific CSS.
|
||||
*/
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
0. ICON BUTTONS (header bar)
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
background: var(--color-surface-2, #222);
|
||||
border: 1px solid var(--color-border, #2a2a2a);
|
||||
color: var(--color-text-secondary, #aaa);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
position: relative;
|
||||
}
|
||||
.icon-btn:hover {
|
||||
background: var(--color-surface-3, #333);
|
||||
color: var(--color-text-primary, #eee);
|
||||
border-color: var(--color-primary, #F5A623);
|
||||
}
|
||||
.notif-dot {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-error, #ef4444);
|
||||
box-shadow: 0 0 4px var(--color-error, #ef4444);
|
||||
}
|
||||
.notif-dot:empty { display: none; }
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
1. CUSTOM SCROLLBAR (global)
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
@@ -720,6 +754,36 @@ input:disabled, select:disabled, textarea:disabled {
|
||||
.nx-loader--sm .nx-loader__ring:nth-child(2) { inset: 4px; }
|
||||
.nx-loader--sm .nx-loader__ring:nth-child(3) { inset: 8px; }
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
21. TIMELINE
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
.timeline { position: relative; padding-left: 24px; }
|
||||
.timeline::before { content: ''; position: absolute; left: 7px; top: 4px; bottom: 4px; width: 2px; background: var(--color-border, #2a2a2a); }
|
||||
.timeline__item { position: relative; margin-bottom: var(--space-4, 1rem); display: flex; gap: 12px; align-items: flex-start; }
|
||||
.timeline__dot { width: 14px; height: 14px; border-radius: 50%; background: var(--color-primary, #F5A623); border: 3px solid var(--color-surface-1, #1a1a1a); flex-shrink: 0; margin-left: -24px; margin-top: 3px; z-index: 1; }
|
||||
.timeline__dot--green { background: var(--color-success, #22c55e); }
|
||||
.timeline__dot--red { background: var(--color-error, #ef4444); }
|
||||
.timeline__dot--blue { background: #3b82f6; }
|
||||
.timeline__content { flex: 1; }
|
||||
.timeline__date { font-size: 11px; color: var(--color-text-muted, #888); margin-bottom: 2px; }
|
||||
.timeline__title { font-size: 13px; font-weight: 600; color: var(--color-text-primary, #eee); }
|
||||
.timeline__desc { font-size: 12px; color: var(--color-text-secondary, #aaa); margin-top: 2px; }
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
22. KANBAN BOARD
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
.kanban { display: flex; gap: var(--space-4, 1rem); overflow-x: auto; padding-bottom: var(--space-2, 0.5rem); }
|
||||
.kanban__col { min-width: 280px; max-width: 320px; flex: 1; background: var(--color-surface-2, #222); border-radius: var(--radius-lg, 12px); border: 1px solid var(--color-border, #2a2a2a); display: flex; flex-direction: column; max-height: 70vh; }
|
||||
.kanban__col-header { padding: 12px 16px; border-bottom: 1px solid var(--color-border, #2a2a2a); font-size: 13px; font-weight: 700; display: flex; justify-content: space-between; align-items: center; }
|
||||
.kanban__col-count { font-size: 11px; padding: 2px 8px; border-radius: var(--radius-full, 999px); background: var(--color-surface-3, #333); color: var(--color-text-muted, #888); }
|
||||
.kanban__cards { flex: 1; overflow-y: auto; padding: var(--space-3, 0.75rem); display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); }
|
||||
.kanban__card { background: var(--color-surface-1, #1a1a1a); border: 1px solid var(--color-border, #2a2a2a); border-radius: var(--radius-md, 8px); padding: 12px; cursor: grab; transition: all 0.15s; }
|
||||
.kanban__card:hover { border-color: var(--color-primary, #F5A623); transform: translateY(-1px); }
|
||||
.kanban__card-title { font-size: 13px; font-weight: 600; margin-bottom: 4px; }
|
||||
.kanban__card-meta { font-size: 11px; color: var(--color-text-muted, #888); }
|
||||
.kanban__card.dragging { opacity: 0.5; cursor: grabbing; }
|
||||
.kanban__col.drag-over { background: var(--color-surface-3, #333); border-color: var(--color-primary, #F5A623); }
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
25. SAVED FILTERS CHIPS
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
@@ -250,6 +250,10 @@
|
||||
});
|
||||
}
|
||||
inventoryVS.setData(items);
|
||||
// Make columns resizable
|
||||
if (typeof makeTableResizable === 'function') {
|
||||
makeTableResizable('#stockTable');
|
||||
}
|
||||
|
||||
// Pagination
|
||||
var pg = data.pagination || {};
|
||||
|
||||
@@ -599,4 +599,275 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Barcode Scanner Feedback ──────────────────────────────────
|
||||
window.BarcodeFeedback = {
|
||||
_audioCtx: null,
|
||||
success: function() {
|
||||
this._beep(800, 0.1, 'sine');
|
||||
this._flash('#22c55e');
|
||||
if (navigator.vibrate) navigator.vibrate(50);
|
||||
},
|
||||
error: function() {
|
||||
this._beep(200, 0.15, 'square');
|
||||
this._flash('#ef4444');
|
||||
if (navigator.vibrate) navigator.vibrate([80, 50, 80]);
|
||||
},
|
||||
_beep: function(freq, duration, type) {
|
||||
try {
|
||||
if (!this._audioCtx) this._audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
var osc = this._audioCtx.createOscillator();
|
||||
var gain = this._audioCtx.createGain();
|
||||
osc.type = type || 'sine';
|
||||
osc.frequency.value = freq;
|
||||
gain.gain.value = 0.1;
|
||||
osc.connect(gain);
|
||||
gain.connect(this._audioCtx.destination);
|
||||
osc.start();
|
||||
osc.stop(this._audioCtx.currentTime + duration);
|
||||
} catch(e) {}
|
||||
},
|
||||
_flash: function(color) {
|
||||
var el = document.createElement('div');
|
||||
el.style.cssText = 'position:fixed;inset:0;z-index:99999;opacity:0.3;background:' + color + ';pointer-events:none;transition:opacity 0.2s;';
|
||||
document.body.appendChild(el);
|
||||
setTimeout(function() { el.style.opacity = '0'; }, 50);
|
||||
setTimeout(function() { el.remove(); }, 300);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Saved Filters ─────────────────────────────────────────────
|
||||
window.SavedFilters = {
|
||||
_key: function(page) { return 'pos_filters_' + (page || window.location.pathname); },
|
||||
save: function(name, filters) {
|
||||
var key = this._key();
|
||||
var saved = this.list();
|
||||
saved.push({ name: name, filters: filters, created: Date.now() });
|
||||
localStorage.setItem(key, JSON.stringify(saved));
|
||||
},
|
||||
list: function() {
|
||||
try { return JSON.parse(localStorage.getItem(this._key()) || '[]'); } catch(e) { return []; }
|
||||
},
|
||||
remove: function(name) {
|
||||
var saved = this.list().filter(function(f) { return f.name !== name; });
|
||||
localStorage.setItem(this._key(), JSON.stringify(saved));
|
||||
},
|
||||
renderChips: function(containerId, onApply) {
|
||||
var container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
var saved = this.list();
|
||||
if (!saved.length) { container.innerHTML = ''; return; }
|
||||
var html = '';
|
||||
saved.forEach(function(f) {
|
||||
html += '<span class="filter-chip">' + esc(f.name) +
|
||||
'<button class="filter-chip__remove" onclick="SavedFilters.remove(\'' + esc(f.name) + '\');SavedFilters.renderChips(\'' + containerId + '\');">×</button></span>';
|
||||
});
|
||||
container.innerHTML = html;
|
||||
container.querySelectorAll('.filter-chip').forEach(function(chip, i) {
|
||||
chip.addEventListener('click', function(e) {
|
||||
if (e.target.classList.contains('filter-chip__remove')) return;
|
||||
if (onApply) onApply(saved[i].filters);
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ── Resizable Columns ─────────────────────────────────────────
|
||||
window.makeTableResizable = function(tableSelector) {
|
||||
var table = document.querySelector(tableSelector);
|
||||
if (!table) return;
|
||||
var ths = table.querySelectorAll('thead th');
|
||||
ths.forEach(function(th, i) {
|
||||
if (i >= ths.length - 1) return; // skip last column
|
||||
var handle = document.createElement('div');
|
||||
handle.className = 'resize-handle';
|
||||
th.appendChild(handle);
|
||||
|
||||
handle.addEventListener('mousedown', function(e) {
|
||||
e.preventDefault();
|
||||
var startX = e.pageX;
|
||||
var startW = th.offsetWidth;
|
||||
th.classList.add('is-resizing');
|
||||
|
||||
function onMove(ev) {
|
||||
var newW = Math.max(60, startW + (ev.pageX - startX));
|
||||
th.style.width = newW + 'px';
|
||||
th.style.minWidth = newW + 'px';
|
||||
}
|
||||
function onUp() {
|
||||
th.classList.remove('is-resizing');
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
}
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// ── Density / Touch Mode Toggles ──────────────────────────────
|
||||
window.DensityToggle = {
|
||||
set: function(density) {
|
||||
document.documentElement.setAttribute('data-density', density);
|
||||
localStorage.setItem('pos_density', density);
|
||||
},
|
||||
init: function() {
|
||||
var saved = localStorage.getItem('pos_density') || 'normal';
|
||||
document.documentElement.setAttribute('data-density', saved);
|
||||
}
|
||||
};
|
||||
window.TouchModeToggle = {
|
||||
set: function(enabled) {
|
||||
document.documentElement.setAttribute('data-touch', enabled ? 'true' : 'false');
|
||||
localStorage.setItem('pos_touch_mode', enabled ? 'true' : 'false');
|
||||
},
|
||||
init: function() {
|
||||
var saved = localStorage.getItem('pos_touch_mode') === 'true';
|
||||
document.documentElement.setAttribute('data-touch', saved ? 'true' : 'false');
|
||||
}
|
||||
};
|
||||
DensityToggle.init();
|
||||
TouchModeToggle.init();
|
||||
|
||||
// ── Notifications Dropdown (functional) ───────────────────────
|
||||
window.NotificationsDropdown = {
|
||||
_visible: false,
|
||||
_el: null,
|
||||
toggle: function() {
|
||||
if (this._visible) { this.hide(); return; }
|
||||
this.show();
|
||||
},
|
||||
show: function() {
|
||||
if (this._el) this._el.remove();
|
||||
var btn = document.getElementById('notifDropdownBtn');
|
||||
var el = document.createElement('div');
|
||||
el.className = 'notif-dropdown';
|
||||
el.innerHTML = '<div class="notif-dropdown__header">Notificaciones <button onclick="NotificationsDropdown.hide()" style="background:none;border:none;color:var(--color-text-muted);cursor:pointer;font-size:16px;">✕</button></div>' +
|
||||
'<div class="notif-dropdown__list" id="notifDropdownList"><div class="notif-dropdown__empty">Sin notificaciones nuevas</div></div>';
|
||||
if (btn) {
|
||||
btn.parentElement.style.position = 'relative';
|
||||
btn.parentElement.appendChild(el);
|
||||
} else {
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
this._el = el;
|
||||
this._visible = true;
|
||||
this._load();
|
||||
setTimeout(function() {
|
||||
document.addEventListener('click', function handler(e) {
|
||||
if (!el.contains(e.target) && e.target !== btn) {
|
||||
NotificationsDropdown.hide();
|
||||
document.removeEventListener('click', handler);
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
},
|
||||
hide: function() { if (this._el) { this._el.remove(); this._el = null; } this._visible = false; },
|
||||
_load: function() {
|
||||
// Stub: can be wired to /api/notifications endpoint
|
||||
var list = document.getElementById('notifDropdownList');
|
||||
if (!list) return;
|
||||
// Example: show inventory alerts count if available
|
||||
var token = localStorage.getItem('pos_token');
|
||||
if (!token) return;
|
||||
fetch('/pos/api/inventory/alerts', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
var count = (d.counts || {}).critical || 0;
|
||||
if (count > 0) {
|
||||
list.innerHTML = '<div class="notif-dropdown__item notif-dropdown__item--unread" onclick="window.location.href=\'/pos/inventory#alertas\'">' +
|
||||
'<div class="notif-dropdown__icon">⚠️</div>' +
|
||||
'<div class="notif-dropdown__content"><div class="notif-dropdown__title">' + count + ' producto' + (count > 1 ? 's' : '') + ' sin stock</div>' +
|
||||
'<div class="notif-dropdown__time">Stock crítico</div></div></div>';
|
||||
}
|
||||
}).catch(function() {});
|
||||
}
|
||||
};
|
||||
|
||||
// ── Ticket Preview Helper ─────────────────────────────────────
|
||||
window.previewTicket = function(ticketData) {
|
||||
var data = ticketData || {};
|
||||
var items = (data.items || []).map(function(it) {
|
||||
return '<div class="ticket-preview__row"><span>' + (it.quantity || 1) + 'x ' + esc(it.name) + '</span><span>$' + (it.subtotal || 0).toFixed(2) + '</span></div>';
|
||||
}).join('');
|
||||
var html = '<div class="ticket-preview">' +
|
||||
'<div class="ticket-preview__header"><div class="ticket-preview__title">Nexus Autoparts</div><div class="ticket-preview__meta">' + (data.store || 'Sucursal Centro') + '</div></div>' +
|
||||
'<div class="ticket-preview__meta" style="text-align:center;margin-bottom:8px;">' + new Date().toLocaleString('es-MX') + '</div>' +
|
||||
'<div class="ticket-preview__row"><span>Ticket #' + (data.id || '---') + '</span></div>' +
|
||||
'<hr style="border:none;border-top:1px dashed #ccc;margin:8px 0;">' +
|
||||
items +
|
||||
'<hr style="border:none;border-top:1px dashed #ccc;margin:8px 0;">' +
|
||||
'<div class="ticket-preview__row"><span>Subtotal</span><span>$' + (data.subtotal || 0).toFixed(2) + '</span></div>' +
|
||||
'<div class="ticket-preview__row"><span>IVA</span><span>$' + (data.tax || 0).toFixed(2) + '</span></div>' +
|
||||
'<div class="ticket-preview__total"><span>TOTAL</span><span>$' + (data.total || 0).toFixed(2) + '</span></div>' +
|
||||
'<div style="text-align:center;font-size:10px;color:#666;margin-top:12px;">Gracias por su compra</div>' +
|
||||
'</div>';
|
||||
return html;
|
||||
};
|
||||
|
||||
// ── Image Comparator Helper ───────────────────────────────────
|
||||
window.initImageComparator = function(containerSelector) {
|
||||
var container = document.querySelector(containerSelector);
|
||||
if (!container) return;
|
||||
var overlay = container.querySelector('.img-compare__overlay');
|
||||
var handle = container.querySelector('.img-compare__handle');
|
||||
if (!overlay || !handle) return;
|
||||
|
||||
function move(x) {
|
||||
var rect = container.getBoundingClientRect();
|
||||
var pct = Math.max(0, Math.min(100, ((x - rect.left) / rect.width) * 100));
|
||||
overlay.style.width = pct + '%';
|
||||
handle.style.left = pct + '%';
|
||||
}
|
||||
handle.addEventListener('mousedown', function(e) {
|
||||
e.preventDefault();
|
||||
function onMove(ev) { move(ev.pageX); }
|
||||
function onUp() { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
});
|
||||
handle.addEventListener('touchstart', function(e) {
|
||||
function onMove(ev) { move(ev.touches[0].pageX); }
|
||||
function onUp() { document.removeEventListener('touchmove', onMove); document.removeEventListener('touchend', onUp); }
|
||||
document.addEventListener('touchmove', onMove);
|
||||
document.addEventListener('touchend', onUp);
|
||||
});
|
||||
};
|
||||
|
||||
// ── Infinite Scroll Helper ────────────────────────────────────
|
||||
window.InfiniteScroll = function(opts) {
|
||||
opts = opts || {};
|
||||
var container = opts.container || window;
|
||||
var threshold = opts.threshold || 200;
|
||||
var loading = false;
|
||||
var observer = new IntersectionObserver(function(entries) {
|
||||
if (entries[0].isIntersecting && !loading && opts.onLoad) {
|
||||
loading = true;
|
||||
opts.onLoad(function() { loading = false; });
|
||||
}
|
||||
}, { root: container === window ? null : container, rootMargin: threshold + 'px' });
|
||||
var sentinel = document.createElement('div');
|
||||
sentinel.style.cssText = 'height:1px;';
|
||||
(opts.sentinelParent || document.body).appendChild(sentinel);
|
||||
observer.observe(sentinel);
|
||||
return { disconnect: function() { observer.disconnect(); sentinel.remove(); } };
|
||||
};
|
||||
|
||||
// ── Sparkline Renderer ────────────────────────────────────────
|
||||
window.renderSparkline = function(containerSelector, values, opts) {
|
||||
opts = opts || {};
|
||||
var container = typeof containerSelector === 'string' ? document.querySelector(containerSelector) : containerSelector;
|
||||
if (!container || !values || !values.length) return;
|
||||
var max = Math.max.apply(null, values) || 1;
|
||||
var min = Math.min.apply(null, values);
|
||||
var range = max - min || 1;
|
||||
var cls = opts.trend === 'up' ? 'sparkline--up' : (opts.trend === 'down' ? 'sparkline--down' : '');
|
||||
var html = '<div class="sparkline ' + cls + '">';
|
||||
values.forEach(function(v) {
|
||||
var h = Math.max(4, Math.round(((v - min) / range) * 100));
|
||||
html += '<div class="sparkline__bar" style="height:' + h + '%" title="' + (opts.prefix || '') + v + '"></div>';
|
||||
});
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
};
|
||||
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user