feat(ui): POS UI polish kit — skeletons, toasts, empty states, Cmd+K, tooltips, badges, scrollbars, focus rings, bulk toolbar, breadcrumbs, avatars, connection indicator, sparklines, animations, touch mode, image comparator, ticket preview, resizable columns, sticky headers, density mode

This commit is contained in:
2026-05-26 08:39:32 +00:00
parent 3060dab471
commit 23dbf54f3f
18 changed files with 1060 additions and 44 deletions

View File

@@ -49,6 +49,11 @@
// --- Dashboard summary badges ---
function loadSummary() {
var skeletonHtml = '<div class="skeleton skeleton--text" style="width:80%;"></div>';
['inv-total-skus','inv-total-value','inv-low-stock','inv-no-movement'].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.innerHTML = skeletonHtml;
});
apiFetch(API + '/summary').then(function(data) {
if (!data) return;
var totalSkusEl = document.getElementById('inv-total-skus');
@@ -112,6 +117,17 @@
});
}
// Register Cmd+K items
if (typeof registerCmdKItem === 'function') {
registerCmdKItem({ group: 'Inventario', label: 'Ver stock', href: '/pos/inventory#stock', icon: '📦' });
registerCmdKItem({ group: 'Inventario', label: 'Alertas de stock', href: '/pos/inventory#alertas', icon: '⚠️' });
registerCmdKItem({ group: 'Inventario', label: 'Entradas de mercancía', href: '/pos/inventory#entradas', icon: '📥' });
registerCmdKItem({ group: 'Inventario', label: 'Salidas / Ventas', href: '/pos/inventory#salidas', icon: '📤' });
registerCmdKItem({ group: 'Inventario', label: 'Traspasos', href: '/pos/inventory#traspasos', icon: '🚚' });
registerCmdKItem({ group: 'Inventario', label: 'Ajustes', href: '/pos/inventory#ajustes', icon: '⚙️' });
registerCmdKItem({ group: 'Inventario', label: 'Conteos físicos', href: '/pos/inventory#conteos', icon: '🔢' });
}
// Handle hash-based tab switching (e.g. /pos/inventory#alertas)
(function handleHashTab() {
var hash = window.location.hash.replace('#', '');
@@ -206,13 +222,20 @@
var params = new URLSearchParams({ page: currentPage, per_page: 50 });
if (currentSearch) params.set('q', currentSearch);
var tbody = document.getElementById('productTableBody');
if (tbody) tbody.innerHTML = renderSkeletonRows(12, 8);
apiFetch(API + '/items?' + params.toString()).then(function (data) {
if (!data) return;
var tbody = document.getElementById('productTableBody');
var items = data.data || [];
if (!items.length) {
tbody.innerHTML = '<tr><td colspan="12" style="text-align:center;padding:30px;color:var(--color-text-muted);">Sin productos</td></tr>';
tbody.innerHTML = '<tr><td colspan="12">' + renderEmptyState({
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>',
title: 'Sin productos',
subtitle: currentSearch ? 'No se encontraron resultados para "' + esc(currentSearch) + '". Intenta con otro término.' : 'El inventario está vacío. Crea tu primer producto para empezar.',
action: currentSearch ? '<button class="btn btn--ghost btn--sm" onclick="document.getElementById(\'productSearch\').value=\'\';loadItems(1,\'\')">Limpiar búsqueda</button>' : '<button class="btn btn--primary btn--sm" onclick="openCreateModal()">Crear producto</button>'
}) + '</td></tr>';
document.getElementById('productPagination').innerHTML = '';
return;
}
@@ -223,7 +246,7 @@
rowHeight: 48,
buffer: 3,
renderRow: renderInventoryRow,
emptyHtml: '<tr><td colspan="12" style="text-align:center;padding:30px;color:var(--color-text-muted);">Sin productos</td></tr>'
emptyHtml: '<tr><td colspan="12">' + renderEmptyState({ title: 'Sin productos', subtitle: 'El inventario está vacío.' }) + '</td></tr>'
});
}
inventoryVS.setData(items);
@@ -653,14 +676,24 @@
// =====================================================================
function loadAlerts() {
var container = document.getElementById('alertsContent');
if (container) container.innerHTML = '<div style="padding:var(--space-6);">' + renderEmptyState({
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
title: 'Cargando alertas...',
subtitle: 'Revisando el estado del inventario'
}) + '</div>';
apiFetch(API + '/alerts').then(function (data) {
if (!data) return;
var alerts = data.data || [];
var container = document.getElementById('alertsContent');
if (!container) return;
if (!alerts.length) {
container.innerHTML = '<p style="padding:var(--space-6);text-align:center;color:var(--color-text-muted);">Sin alertas activas</p>';
container.innerHTML = renderEmptyState({
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>',
title: 'Todo en orden',
subtitle: 'No hay alertas activas en el inventario. Los niveles de stock están dentro de los límites configurados.'
});
return;
}
@@ -726,7 +759,11 @@
var history = data.data || [];
var html = '';
if (!history.length) {
html = '<p style="color:var(--color-text-muted);text-align:center;padding:var(--space-4);">Sin movimientos</p>';
html = renderEmptyState({
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>',
title: 'Sin movimientos',
subtitle: 'Este producto aún no tiene historial de entradas, salidas ni ajustes.'
});
} else {
html = '<table class="data-table"><thead><tr><th>Fecha</th><th>Tipo</th><th>Cantidad</th><th>Costo</th><th>Empleado</th><th>Notas</th></tr></thead><tbody>';
history.forEach(function (h) {
@@ -763,11 +800,11 @@
headers: token ? { 'Authorization': 'Bearer ' + token } : {}
}).then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) { alert('Error: ' + data.error); return; }
showToast('Artículo eliminado');
if (data.error) { showToast(data.error, 'error', { title: 'Error al eliminar' }); return; }
showToast('El artículo fue eliminado correctamente.', 'ok', { title: 'Eliminado' });
loadItems(currentPage);
if (window.loadInventoryStats) window.loadInventoryStats();
}).catch(function() { alert('Error al eliminar artículo'); });
}).catch(function() { showToast('No se pudo eliminar el artículo. Intenta de nuevo.', 'error', { title: 'Error' }); });
}
// =====================================================================

View File

@@ -76,38 +76,245 @@
window.print();
};
// ── Toast (simple, non-blocking notification) ──────────────────
// Only creates its own toast if the page doesn't already have one.
window.showToast = function(msg, type) {
// ── Toast (enhanced with icons, progress bar, close button, actions) ──
var _toastIcons = {
ok: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>',
error: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
warn: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
info: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>'
};
var _toastTitles = { ok: 'Éxito', error: 'Error', warn: 'Advertencia', info: 'Información' };
window.showToast = function(msg, type, opts) {
type = type || 'info';
opts = opts || {};
var container = document.getElementById('toast-container');
if (!container) {
container = document.createElement('div');
container.id = 'toast-container';
container.style.cssText = 'position:fixed;top:16px;right:16px;z-index:9999;display:flex;flex-direction:column;gap:8px;pointer-events:none;';
document.body.appendChild(container);
}
var colors = {
ok: 'background:#1a7a3a;color:#fff;',
error: 'background:#c0392b;color:#fff;',
warn: 'background:#d4a017;color:#000;',
info: 'background:var(--color-surface-3,#333);color:var(--color-text-primary,#fff);',
};
var toast = document.createElement('div');
toast.style.cssText = (colors[type] || colors.info) +
'padding:10px 20px;border-radius:8px;font-size:14px;font-weight:500;' +
'box-shadow:0 4px 12px rgba(0,0,0,0.3);pointer-events:auto;' +
'animation:slideInRight 0.3s ease;max-width:400px;';
toast.textContent = msg;
toast.className = 'toast toast--' + type;
var iconHtml = '<div class="toast__icon">' + (_toastIcons[type] || _toastIcons.info) + '</div>';
var titleHtml = opts.title ? '<div class="toast__title">' + opts.title + '</div>' : '';
var actionHtml = '';
if (opts.action && opts.action.text) {
actionHtml = '<div class="toast__action"><button onclick="this.closest(\'.toast\').__toastAction()">' + opts.action.text + '</button></div>';
toast.__toastAction = function() {
if (opts.action.callback) opts.action.callback();
_removeToast(toast);
};
}
var progressHtml = '<div class="toast__progress" style="animation-duration:' + (opts.duration || 4000) + 'ms;"></div>';
toast.innerHTML = iconHtml +
'<div class="toast__content">' + titleHtml + '<div class="toast__msg">' + msg + '</div>' + actionHtml + '</div>' +
'<button class="toast__close" onclick="_removeToast(this.closest(\'.toast\'))">✕</button>' +
progressHtml;
container.appendChild(toast);
setTimeout(function() {
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.3s';
setTimeout(function() { toast.remove(); }, 300);
}, 3000);
var timer = setTimeout(function() { _removeToast(toast); }, opts.duration || 4000);
toast.__toastTimer = timer;
toast.addEventListener('mouseenter', function() { clearTimeout(timer); var p = toast.querySelector('.toast__progress'); if (p) p.style.animationPlayState = 'paused'; });
toast.addEventListener('mouseleave', function() { var p = toast.querySelector('.toast__progress'); if (p) p.style.animationPlayState = 'running'; timer = setTimeout(function() { _removeToast(toast); }, 2000); toast.__toastTimer = timer; });
};
window._removeToast = function(toast) {
if (!toast || toast.__toastRemoved) return;
toast.__toastRemoved = true;
if (toast.__toastTimer) clearTimeout(toast.__toastTimer);
toast.style.animation = 'toastSlideOut 0.25s ease forwards';
setTimeout(function() { toast.remove(); }, 260);
};
// ── Skeleton helpers ──────────────────────────────────────────
window.renderSkeletonRows = function(cols, rows) {
rows = rows || 6;
var html = '';
for (var i = 0; i < rows; i++) {
html += '<tr class="skeleton--table-row">';
for (var j = 0; j < cols; j++) {
html += '<td><div class="skeleton"></div></td>';
}
html += '</tr>';
}
return html;
};
window.showSkeleton = function(containerSelector, cols, rows) {
var el = typeof containerSelector === 'string' ? document.querySelector(containerSelector) : containerSelector;
if (!el) return;
el.dataset.originalContent = el.innerHTML;
el.innerHTML = renderSkeletonRows(cols || 6, rows || 6);
};
window.hideSkeleton = function(containerSelector) {
var el = typeof containerSelector === 'string' ? document.querySelector(containerSelector) : containerSelector;
if (!el || el.dataset.originalContent === undefined) return;
el.innerHTML = el.dataset.originalContent;
delete el.dataset.originalContent;
};
// ── Empty state helper ────────────────────────────────────────
window.renderEmptyState = function(opts) {
opts = opts || {};
var icon = opts.icon || '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="9" y1="9" x2="15" y2="15"/><line x1="15" y1="9" x2="9" y2="15"/></svg>';
var title = opts.title || 'Sin datos';
var subtitle = opts.subtitle || 'No hay información disponible en este momento.';
var action = opts.action ? '<div class="empty-state__action">' + opts.action + '</div>' : '';
return '<div class="empty-state">' +
'<div class="empty-state__icon">' + icon + '</div>' +
'<div class="empty-state__title">' + title + '</div>' +
'<div class="empty-state__subtitle">' + subtitle + '</div>' +
action + '</div>';
};
// ── Cmd+K Global Search ───────────────────────────────────────
(function() {
var cmdkOverlay = null, cmdkInput = null, cmdkResults = null, cmdkSelected = -1;
var cmdkItems = [];
function buildCmdK() {
if (cmdkOverlay) return;
cmdkOverlay = document.createElement('div');
cmdkOverlay.className = 'cmdk-overlay';
cmdkOverlay.innerHTML =
'<div class="cmdk-modal" role="dialog" aria-label="Búsqueda global">' +
' <div class="cmdk-input-wrap">' +
' <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="color:var(--color-text-muted);flex-shrink:0;"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>' +
' <input type="text" class="cmdk-input" placeholder="Buscar módulos, productos, clientes..." autocomplete="off">' +
' <span class="cmdk-shortcut">ESC</span>' +
' </div>' +
' <div class="cmdk-results"></div>' +
' <div class="cmdk-footer"><span>↑↓ navegar · ↵ seleccionar</span><span>' + cmdkItems.length + ' resultados</span></div>' +
'</div>';
document.body.appendChild(cmdkOverlay);
cmdkInput = cmdkOverlay.querySelector('.cmdk-input');
cmdkResults = cmdkOverlay.querySelector('.cmdk-results');
cmdkOverlay.addEventListener('click', function(e) { if (e.target === cmdkOverlay) closeCmdK(); });
cmdkInput.addEventListener('input', function() { filterCmdK(this.value); });
cmdkInput.addEventListener('keydown', function(e) {
if (e.key === 'Escape') { closeCmdK(); return; }
if (e.key === 'ArrowDown') { e.preventDefault(); moveCmdK(1); }
if (e.key === 'ArrowUp') { e.preventDefault(); moveCmdK(-1); }
if (e.key === 'Enter') { e.preventDefault(); activateCmdK(); }
});
}
function openCmdK() {
buildCmdK();
cmdkOverlay.classList.add('is-open');
cmdkInput.value = '';
cmdkInput.focus();
filterCmdK('');
}
function closeCmdK() {
if (cmdkOverlay) cmdkOverlay.classList.remove('is-open');
}
function moveCmdK(dir) {
var items = cmdkResults.querySelectorAll('.cmdk-item');
if (!items.length) return;
cmdkSelected += dir;
if (cmdkSelected < 0) cmdkSelected = items.length - 1;
if (cmdkSelected >= items.length) cmdkSelected = 0;
items.forEach(function(it, i) { it.classList.toggle('is-selected', i === cmdkSelected); });
var sel = items[cmdkSelected];
if (sel) sel.scrollIntoView({ block: 'nearest' });
}
function activateCmdK() {
var items = cmdkResults.querySelectorAll('.cmdk-item');
var sel = items[cmdkSelected];
if (sel && sel.dataset.href) { closeCmdK(); window.location.href = sel.dataset.href; }
}
function filterCmdK(q) {
q = (q || '').toLowerCase().trim();
var groups = {};
cmdkItems.forEach(function(item) {
if (!q || item.label.toLowerCase().indexOf(q) !== -1 || (item.keywords || '').toLowerCase().indexOf(q) !== -1) {
groups[item.group] = groups[item.group] || [];
groups[item.group].push(item);
}
});
var html = '';
var total = 0;
Object.keys(groups).forEach(function(g) {
html += '<div class="cmdk-group"><div class="cmdk-group__label">' + g + '</div>';
groups[g].forEach(function(item) {
total++;
html += '<div class="cmdk-item" data-href="' + (item.href || '') + '">' +
'<div class="cmdk-item__icon">' + (item.icon || '→') + '</div>' +
'<div>' + item.label + '</div>' +
(item.meta ? '<div class="cmdk-item__meta">' + item.meta + '</div>' : '') +
'</div>';
});
html += '</div>';
});
if (!total) html = '<div style="padding:24px;text-align:center;color:var(--color-text-muted);">Sin resultados</div>';
cmdkResults.innerHTML = html;
cmdkSelected = 0;
var first = cmdkResults.querySelector('.cmdk-item');
if (first) first.classList.add('is-selected');
var footer = cmdkOverlay.querySelector('.cmdk-footer span:last-child');
if (footer) footer.textContent = total + ' resultados';
}
document.addEventListener('keydown', function(e) {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); openCmdK(); }
});
window.registerCmdKItem = function(item) {
if (!item || !item.label) return;
cmdkItems.push(item);
};
window.openCmdK = openCmdK;
window.closeCmdK = closeCmdK;
})();
// ── Connection indicator helper ───────────────────────────────
window.ConnectionStatus = {
online: function() { return navigator.onLine; },
render: function(containerId) {
var el = document.getElementById(containerId);
if (!el) return;
function update() {
var isOnline = navigator.onLine;
el.className = 'connection-indicator' + (isOnline ? '' : ' connection-indicator--offline');
el.innerHTML = '<span></span>' + (isOnline ? 'En línea' : 'Sin conexión');
}
update();
window.addEventListener('online', update);
window.addEventListener('offline', update);
}
};
// ── Bulk toolbar helper ───────────────────────────────────────
window.renderBulkToolbar = function(count, actionsHtml) {
return '<div class="bulk-toolbar">' +
'<div class="bulk-toolbar__count">' + count + ' seleccionado' + (count !== 1 ? 's' : '') + '</div>' +
'<div class="bulk-toolbar__actions">' + actionsHtml + '</div>' +
'</div>';
};
// ── Entrance animation helper ─────────────────────────────────
window.animateEntrance = function(selector, animClass, stagger) {
animClass = animClass || 'animate-fade-in-up';
stagger = stagger || 0.05;
var els = document.querySelectorAll(selector);
els.forEach(function(el, i) {
el.style.animationDelay = (i * stagger) + 's';
el.classList.add(animClass);
});
};
// ── "Próximamente" placeholder for features not yet built ──────