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:
2026-05-26 09:28:35 +00:00
parent 61bf84b2dc
commit 68d6f81671
4 changed files with 403 additions and 0 deletions

View File

@@ -5,6 +5,40 @@
* Load AFTER tokens.css and common.css, BEFORE page-specific CSS. * 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) 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(2) { inset: 4px; }
.nx-loader--sm .nx-loader__ring:nth-child(3) { inset: 8px; } .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 25. SAVED FILTERS CHIPS
═══════════════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════════════ */

View File

@@ -250,6 +250,10 @@
}); });
} }
inventoryVS.setData(items); inventoryVS.setData(items);
// Make columns resizable
if (typeof makeTableResizable === 'function') {
makeTableResizable('#stockTable');
}
// Pagination // Pagination
var pg = data.pagination || {}; var pg = data.pagination || {};

View File

@@ -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 + '\');">&times;</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;
};
})(); })();

View File

@@ -34,6 +34,16 @@
<span class="theme-bar__label">Sucursal Centro &mdash; Usuario: H. García</span> <span class="theme-bar__label">Sucursal Centro &mdash; Usuario: H. García</span>
</div> </div>
<div class="theme-bar__right"> <div class="theme-bar__right">
<button class="icon-btn" id="notifDropdownBtn" title="Notificaciones" onclick="NotificationsDropdown.toggle()" style="margin-right:8px;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
<span class="notif-dot" id="headerNotifDot"></span>
</button>
<button class="icon-btn" title="Modo táctil" onclick="TouchModeToggle.set(document.documentElement.getAttribute('data-touch') !== 'true')" style="margin-right:4px;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="2" width="16" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>
</button>
<button class="icon-btn" title="Densidad" onclick="DensityToggle.set(document.documentElement.getAttribute('data-density') === 'compact' ? 'normal' : 'compact')" style="margin-right:8px;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg>
</button>
<span class="theme-bar__label">Tema:</span> <span class="theme-bar__label">Tema:</span>
<button class="theme-btn theme-btn--industrial is-active" data-theme-target="industrial" onclick="setTheme('industrial')"> <button class="theme-btn theme-btn--industrial is-active" data-theme-target="industrial" onclick="setTheme('industrial')">
<span class="theme-btn__swatch"></span> <span class="theme-btn__swatch"></span>
@@ -908,6 +918,60 @@
</div> </div>
</div> </div>
<!-- ══════════ Product Timeline Modal ══════════ -->
<div class="inv-modal-overlay" id="productTimelineModal">
<div class="inv-modal inv-modal--wide">
<div class="inv-modal__header">
<h3>Timeline del Producto</h3>
<button class="inv-modal__close" onclick="document.getElementById('productTimelineModal').classList.remove('is-open')">&times;</button>
</div>
<div class="inv-modal__body" id="productTimelineBody">
<div class="timeline">
<div class="timeline__item"><div class="timeline__dot"></div><div class="timeline__content"><div class="timeline__date">--</div><div class="timeline__title">Producto creado</div></div></div>
</div>
</div>
<div class="inv-modal__footer">
<button class="btn btn--ghost" onclick="document.getElementById('productTimelineModal').classList.remove('is-open')">Cerrar</button>
</div>
</div>
</div>
<!-- ══════════ Image Comparator Modal ══════════ -->
<div class="inv-modal-overlay" id="imageCompareModal">
<div class="inv-modal">
<div class="inv-modal__header">
<h3>Comparar Imágenes</h3>
<button class="inv-modal__close" onclick="document.getElementById('imageCompareModal').classList.remove('is-open')">&times;</button>
</div>
<div class="inv-modal__body">
<div class="img-compare" id="imgCompareContainer" style="max-height:400px;">
<img class="img-compare__img" src="" id="imgCompareNew" alt="Nueva" />
<div class="img-compare__overlay">
<img src="" id="imgCompareOld" alt="Anterior" />
</div>
<div class="img-compare__handle"></div>
</div>
</div>
</div>
</div>
<!-- ══════════ Ticket Preview Modal ══════════ -->
<div class="inv-modal-overlay" id="ticketPreviewModal">
<div class="inv-modal" style="max-width:400px;">
<div class="inv-modal__header">
<h3>Vista previa del ticket</h3>
<button class="inv-modal__close" onclick="document.getElementById('ticketPreviewModal').classList.remove('is-open')">&times;</button>
</div>
<div class="inv-modal__body" id="ticketPreviewBody">
<!-- Populated by JS -->
</div>
<div class="inv-modal__footer">
<button class="btn btn--ghost" onclick="document.getElementById('ticketPreviewModal').classList.remove('is-open')">Cerrar</button>
<button class="btn btn--primary" onclick="window.print()">Imprimir</button>
</div>
</div>
</div>
<!-- Offline Banner --> <!-- Offline Banner -->
<div id="offlineBanner" class="banner banner--warning" style="display:none;position:fixed;top:0;left:0;right:0;z-index:9999;border-radius:0;animation:none;"> <div id="offlineBanner" class="banner banner--warning" style="display:none;position:fixed;top:0;left:0;right:0;z-index:9999;border-radius:0;animation:none;">
<span class="banner__icon"></span> <span class="banner__icon"></span>