Compare commits

..

3 Commits

Author SHA1 Message Date
0112b7dca4 feat(pos): integrate design system into POS — payment modal, F-keys, ticket, margins
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 07:31:18 +00:00
9641b0af80 feat(pos): integrate design system for clients, inventory, catalog + offline banner
- Replace customers.html with design system clientes page, add slide panel + wire to customers.js
- Replace inventory.html with design system inventario page, load inventory.js
- Add empty state component to catalog product grid (hidden, shown when no results)
- Add offline banner HTML/CSS to all three pages
- Create offline-banner.js: listens online/offline events, auto-dismisses restored banner after 3s

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 07:28:41 +00:00
21427c4dd2 feat(pos): integrate design system for facturacion, contabilidad, dashboard, config, reportes
Replace 5 POS templates with updated design system pages using tokens.css.
Add routes for dashboard, config, and reports pages.
Create stub JS files for dashboard, config, and reports modules.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 07:26:38 +00:00
15 changed files with 18209 additions and 2370 deletions

View File

@@ -66,6 +66,18 @@ def create_app():
def pos_accounting(): def pos_accounting():
return render_template('accounting.html') return render_template('accounting.html')
@app.route('/pos/dashboard')
def pos_dashboard():
return render_template('dashboard.html')
@app.route('/pos/config')
def pos_config():
return render_template('config.html')
@app.route('/pos/reports')
def pos_reports():
return render_template('reports.html')
@app.route('/pos/static/<path:filename>') @app.route('/pos/static/<path:filename>')
def pos_static(filename): def pos_static(filename):
return send_from_directory('static', filename) return send_from_directory('static', filename)

139
pos/static/js/config.js Normal file
View File

@@ -0,0 +1,139 @@
// /home/Autopartes/pos/static/js/config.js
// Config module: branches, employees, theme, system settings
const Config = (() => {
const API = '/pos/api/config';
function token() {
return localStorage.getItem('pos_token') || '';
}
function checkAuth() {
if (!token()) {
window.location.href = '/pos/login';
return false;
}
return true;
}
function headers() {
return { 'Authorization': `Bearer ${token()}`, 'Content-Type': 'application/json' };
}
// -------------------------------------------------------------------------
// Theme switcher
// -------------------------------------------------------------------------
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
try { localStorage.setItem('nexus-theme', theme); } catch(e) {}
document.querySelectorAll('.theme-btn').forEach(function(btn) {
btn.classList.toggle('is-active', btn.dataset.themeTarget === theme);
});
document.querySelectorAll('.theme-option').forEach(function(opt) {
opt.classList.remove('is-selected');
});
var idx = theme === 'industrial' ? 0 : 1;
var opts = document.querySelectorAll('.theme-option');
if (opts[idx]) opts[idx].classList.add('is-selected');
}
window.setTheme = setTheme;
function selectThemeOption(theme) {
setTheme(theme);
}
window.selectThemeOption = selectThemeOption;
// -------------------------------------------------------------------------
// Live clock
// -------------------------------------------------------------------------
function updateClock() {
var now = new Date();
var hh = String(now.getHours()).padStart(2, '0');
var mm = String(now.getMinutes()).padStart(2, '0');
var el = document.getElementById('live-clock');
if (el) el.textContent = hh + ':' + mm;
}
// -------------------------------------------------------------------------
// API calls using existing config_bp endpoints
// -------------------------------------------------------------------------
async function loadBranches() {
try {
const res = await fetch(`${API}/branches`, { headers: headers() });
if (!res.ok) throw new Error('Failed to load branches');
return await res.json();
} catch (e) {
console.error('Config.loadBranches:', e);
return [];
}
}
async function loadEmployees() {
try {
const res = await fetch(`${API}/employees`, { headers: headers() });
if (!res.ok) throw new Error('Failed to load employees');
return await res.json();
} catch (e) {
console.error('Config.loadEmployees:', e);
return [];
}
}
async function saveBranch(data) {
const res = await fetch(`${API}/branches`, {
method: 'POST',
headers: headers(),
body: JSON.stringify(data)
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || 'Save failed');
}
return res.json();
}
async function saveEmployee(data) {
const res = await fetch(`${API}/employees`, {
method: 'POST',
headers: headers(),
body: JSON.stringify(data)
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || 'Save failed');
}
return res.json();
}
// -------------------------------------------------------------------------
// Init
// -------------------------------------------------------------------------
function init() {
if (!checkAuth()) return;
// Restore theme
try {
var saved = localStorage.getItem('nexus-theme');
if (saved === 'industrial' || saved === 'modern') {
setTheme(saved);
}
} catch(e) {}
// Start clock
updateClock();
setInterval(updateClock, 30000);
// Load initial data
loadBranches();
loadEmployees();
}
document.addEventListener('DOMContentLoaded', init);
return {
init, setTheme, selectThemeOption,
loadBranches, loadEmployees, saveBranch, saveEmployee
};
})();

113
pos/static/js/dashboard.js Normal file
View File

@@ -0,0 +1,113 @@
// /home/Autopartes/pos/static/js/dashboard.js
// Dashboard module: KPIs, charts, summary data
const Dashboard = (() => {
function token() {
return localStorage.getItem('pos_token') || '';
}
function checkAuth() {
if (!token()) {
window.location.href = '/pos/login';
return false;
}
return true;
}
function headers() {
return { 'Authorization': `Bearer ${token()}`, 'Content-Type': 'application/json' };
}
function fmt(n) {
return parseFloat(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
// -------------------------------------------------------------------------
// Theme switcher
// -------------------------------------------------------------------------
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
try { localStorage.setItem('nexus-theme', theme); } catch(e) {}
const btnInd = document.getElementById('btn-industrial');
const btnMod = document.getElementById('btn-modern');
if (btnInd) btnInd.classList.toggle('active', theme === 'industrial');
if (btnMod) btnMod.classList.toggle('active', theme === 'modern');
}
window.setTheme = setTheme;
// -------------------------------------------------------------------------
// Sidebar toggle (mobile)
// -------------------------------------------------------------------------
function toggleSidebar() {
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('sidebar-overlay');
if (!sidebar) return;
const isOpen = sidebar.classList.contains('open');
sidebar.classList.toggle('open', !isOpen);
if (overlay) overlay.classList.toggle('open', !isOpen);
document.body.style.overflow = isOpen ? '' : 'hidden';
}
window.toggleSidebar = toggleSidebar;
function closeSidebar() {
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('sidebar-overlay');
if (sidebar) sidebar.classList.remove('open');
if (overlay) overlay.classList.remove('open');
document.body.style.overflow = '';
}
window.closeSidebar = closeSidebar;
window.addEventListener('resize', function() {
if (window.innerWidth >= 768) closeSidebar();
});
// -------------------------------------------------------------------------
// Period selector
// -------------------------------------------------------------------------
function setPeriod(btn) {
btn.closest('.period-selector').querySelectorAll('.period-btn').forEach(function(b) {
b.classList.remove('active');
});
btn.classList.add('active');
}
window.setPeriod = setPeriod;
// -------------------------------------------------------------------------
// Placeholder API calls
// -------------------------------------------------------------------------
async function loadSalesSummary() {
// TODO: call /pos/api/cashregister/summary or similar
}
async function loadTopProducts() {
// TODO: call /pos/api/inventory/products?sort=sold
}
async function loadRecentSales() {
// TODO: call /pos/api/cashregister/recent
}
// -------------------------------------------------------------------------
// Init
// -------------------------------------------------------------------------
function init() {
if (!checkAuth()) return;
// Restore theme
try {
const saved = localStorage.getItem('nexus-theme');
if (saved === 'industrial' || saved === 'modern') {
setTheme(saved);
}
} catch(e) {}
loadSalesSummary();
loadTopProducts();
loadRecentSales();
}
document.addEventListener('DOMContentLoaded', init);
return { init, loadSalesSummary, loadTopProducts, loadRecentSales, setTheme };
})();

View File

@@ -0,0 +1,46 @@
// /home/Autopartes/pos/static/js/offline-banner.js
// Global offline/online banner controller.
// Include this script in any page that has an #offlineBanner element.
(function () {
'use strict';
var banner = document.getElementById('offlineBanner');
var bannerText = document.getElementById('offlineBannerText');
if (!banner || !bannerText) return;
var dismissTimer = null;
function showOffline() {
clearTimeout(dismissTimer);
banner.className = 'banner banner--error';
banner.style.display = 'flex';
banner.style.animation = 'slideDown 0.35s ease-out forwards';
bannerText.innerHTML = '<strong>Conexion perdida</strong> — Intentando reconectar...';
}
function showOnline() {
clearTimeout(dismissTimer);
banner.className = 'banner banner--success';
banner.style.display = 'flex';
banner.style.animation = 'slideDown 0.35s ease-out forwards';
bannerText.innerHTML = '<strong>Conexion restaurada</strong> — Sincronizando datos...';
// Auto-dismiss after 3 seconds
dismissTimer = setTimeout(function () {
banner.style.animation = 'slideUp 0.3s ease-in forwards';
banner.addEventListener('animationend', function onEnd() {
banner.style.display = 'none';
banner.removeEventListener('animationend', onEnd);
}, { once: true });
}, 3000);
}
// Show warning immediately if already offline
if (!navigator.onLine) {
showOffline();
}
window.addEventListener('offline', showOffline);
window.addEventListener('online', showOnline);
})();

View File

@@ -22,6 +22,7 @@ const POS = (() => {
let canViewCost = false; let canViewCost = false;
let employeeMaxDiscount = 100; let employeeMaxDiscount = 100;
let lastSaleId = null; let lastSaleId = null;
let lastSaleData = null;
let searchTimeout = null; let searchTimeout = null;
let customerSearchTimeout = null; let customerSearchTimeout = null;
@@ -39,6 +40,16 @@ const POS = (() => {
return data; return data;
} }
function showToast(msg) {
const container = document.getElementById('toastContainer');
if (!container) return;
const el = document.createElement('div');
el.className = 'toast';
el.textContent = msg;
container.appendChild(el);
setTimeout(() => el.remove(), 2100);
}
// ─── Init ──────────────────────────── // ─── Init ────────────────────────────
async function init() { async function init() {
// Parse JWT to get employee info // Parse JWT to get employee info
@@ -49,10 +60,19 @@ const POS = (() => {
canViewCost = (payload.permissions || []).includes('pos.view_cost'); canViewCost = (payload.permissions || []).includes('pos.view_cost');
employeeMaxDiscount = payload.max_discount_pct || 100; employeeMaxDiscount = payload.max_discount_pct || 100;
// Show cost/margin columns if permission // Show cost/margin columns and toggle button if permission
if (canViewCost) { if (canViewCost) {
document.getElementById('thCost').style.display = ''; document.getElementById('thCost').style.display = '';
document.getElementById('thMargin').style.display = ''; document.getElementById('thMargin').style.display = '';
const costToggle = document.getElementById('costToggle');
if (costToggle) costToggle.style.display = '';
}
// Set avatar initials
const avatar = document.querySelector('.status-bar__user-avatar');
if (avatar && payload.name) {
const parts = payload.name.split(' ');
avatar.textContent = parts.map(p => p[0]).join('').substring(0, 2).toUpperCase();
} }
} catch (e) { } catch (e) {
console.warn('Could not parse token:', e); console.warn('Could not parse token:', e);
@@ -87,11 +107,9 @@ const POS = (() => {
currentRegister = data.register; currentRegister = data.register;
document.getElementById('registerInfo').innerHTML = document.getElementById('registerInfo').innerHTML =
`<span>Caja #${data.register.register_number}</span>`; `<span>Caja #${data.register.register_number}</span>`;
document.getElementById('registerInfo').classList.remove('no-register');
} else { } else {
document.getElementById('registerInfo').innerHTML = document.getElementById('registerInfo').innerHTML =
'<span>Sin caja abierta</span>'; '<span>Sin caja abierta</span>';
document.getElementById('registerInfo').classList.add('no-register');
} }
} catch (e) { } catch (e) {
console.warn('Register check failed:', e); console.warn('Register check failed:', e);
@@ -100,7 +118,6 @@ const POS = (() => {
// ─── Cart ──────────────────────────── // ─── Cart ────────────────────────────
function addToCart(item) { function addToCart(item) {
// Check if item already in cart
const existing = cart.find(c => c.inventory_id === item.inventory_id); const existing = cart.find(c => c.inventory_id === item.inventory_id);
if (existing) { if (existing) {
existing.quantity += (item.quantity || 1); existing.quantity += (item.quantity || 1);
@@ -121,6 +138,7 @@ const POS = (() => {
}); });
renderCart(); renderCart();
showToast(`${item.name || 'Articulo'} agregado`);
} }
function removeFromCart(index) { function removeFromCart(index) {
@@ -150,36 +168,39 @@ const POS = (() => {
const lineDiscount = lineGross * item.discount_pct / 100; const lineDiscount = lineGross * item.discount_pct / 100;
const lineSubtotal = lineGross - lineDiscount; const lineSubtotal = lineGross - lineDiscount;
const costHtml = canViewCost ? `<td class="num">${fmt(item.unit_cost)}</td>` : ''; const costHtml = canViewCost ? `<td class="num" style="text-align:right;padding:var(--space-2);font-family:var(--font-mono);color:var(--color-text-muted);font-size:var(--text-caption);">${fmt(item.unit_cost)}</td>` : '';
let marginHtml = ''; let marginHtml = '';
if (canViewCost) { if (canViewCost) {
const effectivePrice = item.unit_price * (1 - item.discount_pct / 100); const effectivePrice = item.unit_price * (1 - item.discount_pct / 100);
const marginPct = effectivePrice > 0 const marginPct = effectivePrice > 0
? ((effectivePrice - item.unit_cost) / effectivePrice * 100).toFixed(1) ? ((effectivePrice - item.unit_cost) / effectivePrice * 100).toFixed(1)
: '0.0'; : '0.0';
const cls = parseFloat(marginPct) < 5 ? 'margin-warning' : 'margin-info'; const marginVal = parseFloat(marginPct);
marginHtml = `<td class="num"><span class="${cls}">${marginPct}%</span></td>`; const cls = marginVal > 30 ? 'margin-high' : marginVal > 15 ? 'margin-mid' : 'margin-low';
const color = marginVal > 30 ? 'var(--color-success)' : marginVal > 15 ? 'var(--color-warning)' : 'var(--color-error)';
marginHtml = `<td style="text-align:center;padding:var(--space-2);"><span style="font-family:var(--font-mono);font-size:var(--text-caption);font-weight:var(--font-weight-bold);color:${color};">${marginPct}%</span></td>`;
} }
html += `<tr class="${i === selectedRow ? 'selected' : ''}" onclick="POS.selectRow(${i})"> html += `<tr style="border-bottom:1px solid var(--color-border);cursor:pointer;${i === selectedRow ? 'background:var(--color-primary-muted);' : ''}" onclick="POS.selectRow(${i})">
<td>${i + 1}</td> <td style="padding:var(--space-2);color:var(--color-text-muted);">${i + 1}</td>
<td> <td style="padding:var(--space-2);">
<div class="part-name">${item.name}</div> <div style="font-weight:var(--font-weight-semibold);color:var(--color-text-primary);">${item.name}</div>
<div class="part-number">${item.part_number} | Stock: ${item.stock}</div> <div style="font-family:var(--font-mono);font-size:var(--text-caption);color:var(--color-text-muted);">${item.part_number} | Stock: ${item.stock}</div>
</td> </td>
<td><input type="number" class="qty-input" value="${item.quantity}" min="1" <td style="text-align:center;padding:var(--space-2);"><input type="number" style="width:50px;text-align:center;font-family:var(--font-mono);background:var(--color-bg-base);color:var(--color-text-primary);border:1px solid var(--color-border);border-radius:var(--radius-sm);padding:2px 4px;" value="${item.quantity}" min="1"
onchange="POS.updateQty(${i}, this.value)" onclick="event.stopPropagation()"></td> onchange="POS.updateQty(${i}, this.value)" onclick="event.stopPropagation()"></td>
<td class="num">${fmt(item.unit_price)}</td> <td style="text-align:right;padding:var(--space-2);font-family:var(--font-mono);">${fmt(item.unit_price)}</td>
<td><input type="number" class="discount-input" value="${item.discount_pct}" min="0" max="100" step="0.5" <td style="text-align:center;padding:var(--space-2);"><input type="number" style="width:45px;text-align:center;font-family:var(--font-mono);background:var(--color-bg-base);color:var(--color-text-primary);border:1px solid var(--color-border);border-radius:var(--radius-sm);padding:2px 4px;" value="${item.discount_pct}" min="0" max="100" step="0.5"
onchange="POS.updateDiscount(${i}, this.value)" onclick="event.stopPropagation()">%</td> onchange="POS.updateDiscount(${i}, this.value)" onclick="event.stopPropagation()">%</td>
<td class="num">${fmt(lineSubtotal)}</td> <td style="text-align:right;padding:var(--space-2);font-family:var(--font-mono);font-weight:var(--font-weight-bold);">${fmt(lineSubtotal)}</td>
${costHtml} ${costHtml}
${marginHtml} ${marginHtml}
<td><button class="btn-remove" onclick="event.stopPropagation(); POS.removeFromCart(${i})">&times;</button></td> <td style="text-align:center;"><button style="background:transparent;border:1px solid var(--color-border);border-radius:var(--radius-sm);width:24px;height:24px;cursor:pointer;color:var(--color-text-muted);font-size:14px;" onclick="event.stopPropagation(); POS.removeFromCart(${i})">&times;</button></td>
</tr>`; </tr>`;
}); });
tbody.innerHTML = html; tbody.innerHTML = html;
updateTotals(); updateTotals();
updateAvgMargin();
} }
function updateQty(index, val) { function updateQty(index, val) {
@@ -230,6 +251,26 @@ const POS = (() => {
} }
} }
function updateAvgMargin() {
const avgEl = document.getElementById('avgMargin');
if (!avgEl || !canViewCost) return;
let totalRevenue = 0, totalCost = 0;
cart.forEach(item => {
if (item.unit_cost > 0) {
const effectivePrice = item.unit_price * (1 - item.discount_pct / 100);
totalRevenue += effectivePrice * item.quantity;
totalCost += item.unit_cost * item.quantity;
}
});
if (totalRevenue > 0) {
const avg = ((totalRevenue - totalCost) / totalRevenue * 100);
avgEl.textContent = avg.toFixed(1) + '%';
avgEl.style.color = avg > 30 ? 'var(--color-success)' : avg > 15 ? 'var(--color-warning)' : 'var(--color-error)';
} else {
avgEl.textContent = '--';
}
}
function getTotal() { function getTotal() {
let subtotal = 0, taxTotal = 0; let subtotal = 0, taxTotal = 0;
cart.forEach(item => { cart.forEach(item => {
@@ -243,9 +284,14 @@ const POS = (() => {
return Math.round((subtotal + taxTotal) * 100) / 100; return Math.round((subtotal + taxTotal) * 100) / 100;
} }
function getItemCount() {
return cart.reduce((sum, item) => sum + item.quantity, 0);
}
// ─── Search ────────────────────────── // ─── Search ──────────────────────────
function setupSearch() { function setupSearch() {
const input = document.getElementById('itemSearch'); const input = document.getElementById('itemSearch');
if (!input) return;
input.addEventListener('input', () => { input.addEventListener('input', () => {
clearTimeout(searchTimeout); clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => searchItems(input.value.trim()), 300); searchTimeout = setTimeout(() => searchItems(input.value.trim()), 300);
@@ -271,31 +317,29 @@ const POS = (() => {
const totals = document.getElementById('totalsPanel'); const totals = document.getElementById('totalsPanel');
if (data.data.length === 0) { if (data.data.length === 0) {
container.innerHTML = '<div style="padding:20px;text-align:center;color:#999;">Sin resultados</div>'; container.innerHTML = '<div style="padding:20px;text-align:center;color:var(--color-text-muted);">Sin resultados</div>';
} else { } else {
let html = ''; let html = '';
data.data.forEach(item => { data.data.forEach(item => {
// Determine price for current customer
let price = item.price_1; let price = item.price_1;
if (currentCustomer) { if (currentCustomer) {
const tier = currentCustomer.price_tier || 1; const tier = currentCustomer.price_tier || 1;
price = tier === 3 ? item.price_3 : tier === 2 ? item.price_2 : item.price_1; price = tier === 3 ? item.price_3 : tier === 2 ? item.price_2 : item.price_1;
} }
const stockClass = item.stock <= 0 ? 'zero' : ''; html += `<div style="padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--color-border);cursor:pointer;display:flex;justify-content:space-between;align-items:center;transition:var(--transition-fast);" onmouseover="this.style.background='var(--color-primary-muted)'" onmouseout="this.style.background=''" onclick='POS.addFromSearch(${JSON.stringify(item).replace(/'/g, "&#39;")}, ${price})'>
html += `<div class="search-result-item" onclick='POS.addFromSearch(${JSON.stringify(item).replace(/'/g, "&#39;")}, ${price})'>
<div> <div>
<div class="sr-name">${item.name}</div> <div style="font-weight:var(--font-weight-semibold);">${item.name}</div>
<div class="sr-pn">${item.part_number} | ${item.brand || ''}</div> <div style="font-family:var(--font-mono);font-size:var(--text-caption);color:var(--color-text-muted);">${item.part_number} | ${item.brand || ''}</div>
<div class="sr-stock ${stockClass}">Stock: ${item.stock}</div> <div style="font-size:var(--text-caption);color:${item.stock <= 0 ? 'var(--color-error)' : 'var(--color-text-muted)'};">Stock: ${item.stock}</div>
</div> </div>
<div class="sr-price">${fmt(price)}</div> <div style="font-family:var(--font-mono);font-weight:var(--font-weight-bold);color:var(--color-primary);">${fmt(price)}</div>
</div>`; </div>`;
}); });
container.innerHTML = html; container.innerHTML = html;
} }
container.classList.add('active'); container.style.display = '';
totals.classList.add('hidden'); totals.style.display = 'none';
} catch (e) { } catch (e) {
console.error('Search error:', e); console.error('Search error:', e);
} }
@@ -317,13 +361,16 @@ const POS = (() => {
} }
function hideSearchResults() { function hideSearchResults() {
document.getElementById('searchResults').classList.remove('active'); const sr = document.getElementById('searchResults');
document.getElementById('totalsPanel').classList.remove('hidden'); const tp = document.getElementById('totalsPanel');
if (sr) sr.style.display = 'none';
if (tp) tp.style.display = '';
} }
// ─── Customer Search ───────────────── // ─── Customer Search ─────────────────
function setupCustomerSearch() { function setupCustomerSearch() {
const input = document.getElementById('customerSearch'); const input = document.getElementById('customerSearch');
if (!input) return;
input.addEventListener('input', () => { input.addEventListener('input', () => {
clearTimeout(customerSearchTimeout); clearTimeout(customerSearchTimeout);
customerSearchTimeout = setTimeout(() => searchCustomers(input.value.trim()), 300); customerSearchTimeout = setTimeout(() => searchCustomers(input.value.trim()), 300);
@@ -347,14 +394,14 @@ const POS = (() => {
const ac = document.getElementById('customerAutocomplete'); const ac = document.getElementById('customerAutocomplete');
if (data.data.length === 0) { if (data.data.length === 0) {
ac.innerHTML = '<div class="ac-item" style="color:#999;">Sin resultados</div>'; ac.innerHTML = '<div style="padding:var(--space-3);color:var(--color-text-muted);">Sin resultados</div>';
} else { } else {
let html = ''; let html = '';
data.data.forEach(c => { data.data.forEach(c => {
const tiers = { 1: 'Mostrador', 2: 'Taller', 3: 'Mayoreo' }; const tiers = { 1: 'Mostrador', 2: 'Taller', 3: 'Mayoreo' };
html += `<div class="ac-item" onclick='POS.selectCustomer(${JSON.stringify(c).replace(/'/g, "&#39;")})'> html += `<div style="padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--color-border);cursor:pointer;transition:var(--transition-fast);" onmouseover="this.style.background='var(--color-primary-muted)'" onmouseout="this.style.background=''" onclick='POS.selectCustomer(${JSON.stringify(c).replace(/'/g, "&#39;")})'>
<div>${c.name}</div> <div style="font-weight:var(--font-weight-semibold);">${c.name}</div>
<div class="ac-meta">${c.rfc || ''} | ${c.phone || ''} | ${tiers[c.price_tier] || 'P1'} | Credito: ${fmt(c.credit_balance)}/${fmt(c.credit_limit)}</div> <div style="font-size:var(--text-caption);color:var(--color-text-muted);">${c.rfc || ''} | ${c.phone || ''} | ${tiers[c.price_tier] || 'P1'} | Credito: ${fmt(c.credit_balance)}/${fmt(c.credit_limit)}</div>
</div>`; </div>`;
}); });
ac.innerHTML = html; ac.innerHTML = html;
@@ -377,7 +424,7 @@ const POS = (() => {
`Credito: ${fmt(customer.credit_balance)} / ${fmt(customer.credit_limit)}`; `Credito: ${fmt(customer.credit_balance)} / ${fmt(customer.credit_limit)}`;
document.getElementById('customerSelected').style.display = ''; document.getElementById('customerSelected').style.display = '';
// Show vehicle info // Show vehicle info banner
if (customer.vehicle_info && customer.vehicle_info.length > 0) { if (customer.vehicle_info && customer.vehicle_info.length > 0) {
const v = customer.vehicle_info[0]; const v = customer.vehicle_info[0];
document.getElementById('vehicleInfo').textContent = document.getElementById('vehicleInfo').textContent =
@@ -385,7 +432,7 @@ const POS = (() => {
document.getElementById('vehicleBanner').classList.add('visible'); document.getElementById('vehicleBanner').classList.add('visible');
} }
// Fetch full customer detail to get recent purchase info // Fetch full customer detail
try { try {
const detail = await api(`/pos/api/customers/${customer.id}`); const detail = await api(`/pos/api/customers/${customer.id}`);
if (detail.recent_purchases && detail.recent_purchases.length > 0) { if (detail.recent_purchases && detail.recent_purchases.length > 0) {
@@ -393,18 +440,19 @@ const POS = (() => {
const daysAgo = Math.floor((Date.now() - new Date(last.created_at).getTime()) / 86400000); const daysAgo = Math.floor((Date.now() - new Date(last.created_at).getTime()) / 86400000);
const daysText = daysAgo === 0 ? 'hoy' : daysAgo === 1 ? 'hace 1 dia' : `hace ${daysAgo} dias`; const daysText = daysAgo === 0 ? 'hoy' : daysAgo === 1 ? 'hace 1 dia' : `hace ${daysAgo} dias`;
document.getElementById('lastPurchaseInfo').textContent = document.getElementById('lastPurchaseInfo').textContent =
`Ultima compra: ${fmt(last.total)} ${daysText}`; `${fmt(last.total)} ${daysText}`;
document.getElementById('vehicleBanner').classList.add('visible'); document.getElementById('vehicleBanner').classList.add('visible');
} }
} catch (e) { } catch (e) {
console.warn('Could not fetch customer detail:', e); console.warn('Could not fetch customer detail:', e);
} }
// Re-apply prices based on customer tier // Update CFDI hint
cart.forEach(item => { const cfdiHint = document.getElementById('cfdiHint');
// Fetch updated price for this customer tier (would need to re-query) if (cfdiHint && customer.rfc) {
// For now, prices stay as-is (they were set at add time) cfdiHint.textContent = `RFC: ${customer.rfc}`;
}); document.getElementById('cfdiCheck').checked = !!customer.rfc;
}
renderCart(); renderCart();
} }
@@ -412,20 +460,25 @@ const POS = (() => {
function clearCustomer() { function clearCustomer() {
currentCustomer = null; currentCustomer = null;
document.getElementById('customerSelected').style.display = 'none'; document.getElementById('customerSelected').style.display = 'none';
document.getElementById('customerSearchWrap').querySelector('input').style.display = ''; const searchInput = document.getElementById('customerSearch');
document.getElementById('customerSearchWrap').querySelector('input').value = ''; if (searchInput) {
searchInput.style.display = '';
searchInput.value = '';
}
document.getElementById('vehicleBanner').classList.remove('visible'); document.getElementById('vehicleBanner').classList.remove('visible');
const cfdiHint = document.getElementById('cfdiHint');
if (cfdiHint) cfdiHint.textContent = '';
renderCart(); renderCart();
} }
// ─── New Customer Modal ────────────── // ─── New Customer Modal ──────────────
function showNewCustomerModal() { function showNewCustomerModal() {
document.getElementById('newCustomerModal').classList.add('active'); document.getElementById('newCustomerModal').classList.add('open');
document.getElementById('ncName').focus(); setTimeout(() => document.getElementById('ncName').focus(), 100);
} }
function closeNewCustomerModal() { function closeNewCustomerModal() {
document.getElementById('newCustomerModal').classList.remove('active'); document.getElementById('newCustomerModal').classList.remove('open');
} }
async function saveNewCustomer() { async function saveNewCustomer() {
@@ -462,7 +515,6 @@ const POS = (() => {
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
// Select the new customer
selectCustomer({ selectCustomer({
id: result.id, id: result.id,
name: body.name, name: body.name,
@@ -475,6 +527,7 @@ const POS = (() => {
}); });
closeNewCustomerModal(); closeNewCustomerModal();
showToast('Cliente creado');
} catch (e) { } catch (e) {
alert('Error al crear cliente: ' + e.message); alert('Error al crear cliente: ' + e.message);
} }
@@ -482,40 +535,88 @@ const POS = (() => {
// ─── Payment ───────────────────────── // ─── Payment ─────────────────────────
function checkout() { function checkout() {
if (cart.length === 0) { alert('Carrito vacio'); return; } if (cart.length === 0) { showToast('Carrito vacio'); return; }
if (!currentRegister) { alert('No hay caja abierta. Abra una caja primero.'); return; } if (!currentRegister) { alert('No hay caja abierta. Abra una caja primero.'); return; }
paymentMethod = 'efectivo'; paymentMethod = 'efectivo';
const total = getTotal(); const total = getTotal();
// Populate modal
document.getElementById('modalTotal').textContent = fmt(total); document.getElementById('modalTotal').textContent = fmt(total);
document.getElementById('cashReceived').value = ''; document.getElementById('modalItemCount').textContent = `${getItemCount()} productos`;
document.getElementById('changeDisplay').textContent = 'Cambio: $0.00'; document.getElementById('modalCustomerName').textContent =
document.getElementById('changeDisplay').className = 'change-display positive'; currentCustomer ? currentCustomer.name : 'Publico General';
document.getElementById('paymentRef').value = '';
// Reset payment method buttons // Reset inputs
document.querySelectorAll('.pm-btn').forEach(b => b.classList.remove('active')); document.getElementById('cashReceived').value = '';
document.querySelector('.pm-btn[data-method="efectivo"]').classList.add('active'); document.getElementById('changeDisplay').textContent = '$0.00';
document.getElementById('changeDisplay').className = 'cambio-amount positive';
const payRef = document.getElementById('paymentRef');
if (payRef) payRef.value = '';
// Populate quick amounts
const quickContainer = document.getElementById('quickAmounts');
if (quickContainer) {
const rounded = Math.ceil(total);
const amounts = [rounded, Math.ceil(total / 100) * 100, Math.ceil(total / 500) * 500, Math.ceil(total / 1000) * 1000];
const unique = [...new Set(amounts)].slice(0, 4);
quickContainer.innerHTML = unique.map(a =>
`<button class="quick-btn" onclick="document.getElementById('cashReceived').value=${a};POS.updateChange();">${fmt(a)}</button>`
).join('');
}
// Set ref amount
const refAmount = document.getElementById('refAmount');
if (refAmount) refAmount.value = fmt(total);
// Reset payment tabs
document.querySelectorAll('.pago-tab').forEach(t => t.classList.remove('active'));
document.querySelector('.pago-tab[data-method="efectivo"]').classList.add('active');
document.getElementById('cashPayment').classList.add('active');
document.getElementById('cashPayment').style.display = ''; document.getElementById('cashPayment').style.display = '';
document.getElementById('refPayment').classList.remove('active');
document.getElementById('refPayment').style.display = 'none'; document.getElementById('refPayment').style.display = 'none';
document.getElementById('mixedPayment').classList.remove('active');
document.getElementById('mixedPayment').style.display = 'none'; document.getElementById('mixedPayment').style.display = 'none';
document.getElementById('paymentModal').classList.add('active'); // Reset confirm button
const confirmBtn = document.getElementById('btnConfirmPayment');
confirmBtn.disabled = false;
confirmBtn.textContent = `Confirmar Pago — ${fmt(total)}`;
document.getElementById('paymentModal').classList.add('open');
setTimeout(() => document.getElementById('cashReceived').focus(), 100); setTimeout(() => document.getElementById('cashReceived').focus(), 100);
} }
function selectPaymentMethod(method, btn) { function selectPaymentMethod(method, btn) {
paymentMethod = method; paymentMethod = method;
document.querySelectorAll('.pm-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.getElementById('cashPayment').style.display = method === 'efectivo' ? '' : 'none'; // Update tabs
document.getElementById('refPayment').style.display = (method === 'transferencia' || method === 'tarjeta') ? '' : 'none'; document.querySelectorAll('.pago-tab').forEach(t => t.classList.remove('active'));
document.getElementById('mixedPayment').style.display = method === 'mixto' ? '' : 'none'; if (btn) btn.classList.add('active');
// Show/hide tab content
const tabs = {
efectivo: 'cashPayment',
transferencia: 'refPayment',
tarjeta: 'refPayment',
mixto: 'mixedPayment',
};
['cashPayment', 'refPayment', 'mixedPayment'].forEach(id => {
const el = document.getElementById(id);
if (el) {
const isActive = el.id === tabs[method];
el.classList.toggle('active', isActive);
el.style.display = isActive ? '' : 'none';
}
});
if (method === 'efectivo') document.getElementById('cashReceived').focus(); if (method === 'efectivo') document.getElementById('cashReceived').focus();
if (method === 'transferencia' || method === 'tarjeta') document.getElementById('paymentRef').focus(); if (method === 'transferencia' || method === 'tarjeta') {
const ref = document.getElementById('paymentRef');
if (ref) ref.focus();
}
} }
function updateChange() { function updateChange() {
@@ -523,8 +624,13 @@ const POS = (() => {
const received = parseFloat(document.getElementById('cashReceived').value) || 0; const received = parseFloat(document.getElementById('cashReceived').value) || 0;
const change = received - total; const change = received - total;
const el = document.getElementById('changeDisplay'); const el = document.getElementById('changeDisplay');
el.textContent = `Cambio: ${fmt(Math.abs(change))}`; if (change >= 0) {
el.className = 'change-display ' + (change >= 0 ? 'positive' : 'negative'); el.textContent = fmt(change);
el.className = 'cambio-amount positive';
} else {
el.textContent = '-' + fmt(Math.abs(change));
el.className = 'cambio-amount negative';
}
} }
function updateMixedTotal() { function updateMixedTotal() {
@@ -534,13 +640,13 @@ const POS = (() => {
sum += parseFloat(input.value) || 0; sum += parseFloat(input.value) || 0;
}); });
const remaining = total - sum; const remaining = total - sum;
document.getElementById('mixedRemaining').textContent = const el = document.getElementById('mixedRemaining');
remaining > 0 ? `Faltante: ${fmt(remaining)}` : `Cubierto (${fmt(sum)})`; el.textContent = remaining > 0 ? `Faltante: ${fmt(remaining)}` : `Cubierto (${fmt(sum)})`;
document.getElementById('mixedRemaining').style.color = remaining > 0 ? '#c62828' : '#2e7d32'; el.style.color = remaining > 0 ? 'var(--color-error)' : 'var(--color-success)';
} }
function closePaymentModal() { function closePaymentModal() {
document.getElementById('paymentModal').classList.remove('active'); document.getElementById('paymentModal').classList.remove('open');
} }
async function confirmPayment() { async function confirmPayment() {
@@ -584,10 +690,12 @@ const POS = (() => {
amount_paid: amountPaid, amount_paid: amountPaid,
payment_details: paymentDetails, payment_details: paymentDetails,
reference: reference, reference: reference,
generate_cfdi: document.getElementById('cfdiCheck').checked,
}; };
document.getElementById('btnConfirmPayment').disabled = true; const confirmBtn = document.getElementById('btnConfirmPayment');
document.getElementById('btnConfirmPayment').textContent = 'Procesando...'; confirmBtn.disabled = true;
confirmBtn.textContent = 'Procesando...';
try { try {
const sale = await api('/pos/api/sales', { const sale = await api('/pos/api/sales', {
@@ -596,6 +704,7 @@ const POS = (() => {
}); });
lastSaleId = sale.id; lastSaleId = sale.id;
lastSaleData = sale;
closePaymentModal(); closePaymentModal();
showTicket(sale); showTicket(sale);
@@ -605,11 +714,12 @@ const POS = (() => {
clearCustomer(); clearCustomer();
renderCart(); renderCart();
showToast(`Venta #${sale.id} completada`);
} catch (e) { } catch (e) {
alert('Error al procesar venta: ' + e.message); alert('Error al procesar venta: ' + e.message);
} finally { } finally {
document.getElementById('btnConfirmPayment').disabled = false; confirmBtn.disabled = false;
document.getElementById('btnConfirmPayment').textContent = 'Confirmar Pago'; confirmBtn.textContent = 'Confirmar Pago';
} }
} }
@@ -623,7 +733,7 @@ const POS = (() => {
const available = (currentCustomer.credit_limit || 0) - (currentCustomer.credit_balance || 0); const available = (currentCustomer.credit_limit || 0) - (currentCustomer.credit_balance || 0);
if (currentCustomer.credit_limit > 0 && total > available) { if (currentCustomer.credit_limit > 0 && total > available) {
if (!confirm(`Credito insuficiente. Disponible: ${fmt(available)}, Total: ${fmt(total)}. Continuar de todas formas?`)) { if (!confirm(`Credito insuficiente. Disponible: ${fmt(available)}, Total: ${fmt(total)}. Continuar?`)) {
return; return;
} }
} }
@@ -650,6 +760,7 @@ const POS = (() => {
}); });
lastSaleId = sale.id; lastSaleId = sale.id;
lastSaleData = sale;
showTicket(sale); showTicket(sale);
cart = []; cart = [];
selectedRow = -1; selectedRow = -1;
@@ -662,7 +773,7 @@ const POS = (() => {
// ─── Quotation ─────────────────────── // ─── Quotation ───────────────────────
async function saveQuotation() { async function saveQuotation() {
if (cart.length === 0) { alert('Carrito vacio'); return; } if (cart.length === 0) { showToast('Carrito vacio'); return; }
const body = { const body = {
items: cart.map(item => ({ items: cart.map(item => ({
@@ -680,7 +791,7 @@ const POS = (() => {
method: 'POST', method: 'POST',
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
alert(`Cotizacion #${result.id} guardada. Total: ${fmt(result.total)}\nValida hasta: ${result.valid_until}`); showToast(`Cotizacion #${result.id} guardada. Total: ${fmt(result.total)}`);
} catch (e) { } catch (e) {
alert('Error: ' + e.message); alert('Error: ' + e.message);
} }
@@ -718,13 +829,7 @@ const POS = (() => {
method: 'POST', method: 'POST',
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
alert( showToast(`Apartado #${result.id} creado. Restante: ${fmt(result.remaining)}`);
`Apartado #${result.id} creado.\n` +
`Total: ${fmt(result.total)}\n` +
`Anticipo: ${fmt(result.amount_paid)}\n` +
`Restante: ${fmt(result.remaining)}\n` +
`Vence: ${result.expires_at}`
);
cart = []; cart = [];
selectedRow = -1; selectedRow = -1;
clearCustomer(); clearCustomer();
@@ -736,64 +841,108 @@ const POS = (() => {
// ─── Ticket ────────────────────────── // ─── Ticket ──────────────────────────
function showTicket(sale) { function showTicket(sale) {
const lines = []; const dateStr = new Date(sale.created_at).toLocaleString('es-MX', {
lines.push('========================================'); year: 'numeric', month: 'short', day: 'numeric',
lines.push(' NEXUS POS'); hour: '2-digit', minute: '2-digit'
lines.push('========================================');
lines.push(`Venta #${sale.id}`);
lines.push(`Fecha: ${new Date(sale.created_at).toLocaleString('es-MX')}`);
if (currentCustomer) {
lines.push(`Cliente: ${currentCustomer.name}`);
if (currentCustomer.rfc) lines.push(`RFC: ${currentCustomer.rfc}`);
} else {
lines.push('Cliente: Publico General');
}
lines.push('----------------------------------------');
(sale.items || []).forEach(item => {
lines.push(`${item.name}`);
let line = ` ${item.quantity} x ${fmt(item.unit_price)}`;
if (item.discount_pct > 0) line += ` (-${item.discount_pct}%)`;
line += ` ${fmt(item.subtotal)}`;
lines.push(line);
}); });
lines.push('----------------------------------------'); const customerName = currentCustomer ? currentCustomer.name : 'Publico General';
lines.push(`Subtotal: ${fmt(sale.subtotal).padStart(12)}`); const customerRfc = currentCustomer && currentCustomer.rfc ? currentCustomer.rfc : '';
if (sale.discount_total > 0) {
lines.push(`Descuento: -${fmt(sale.discount_total).padStart(12)}`);
}
lines.push(`IVA: ${fmt(sale.tax_total).padStart(12)}`);
lines.push('========================================');
lines.push(`TOTAL: ${fmt(sale.total).padStart(12)}`);
lines.push('========================================');
if (sale.payment_method === 'efectivo') { let itemsHtml = '';
lines.push(`Efectivo: ${fmt(sale.amount_paid).padStart(12)}`); (sale.items || []).forEach(item => {
lines.push(`Cambio: ${fmt(sale.change_given).padStart(12)}`); const itemTotal = (item.unit_price * item.quantity * (1 - (item.discount_pct || 0) / 100));
} else { itemsHtml += `
lines.push(`Pago: ${sale.payment_method}`); <div class="item-line-wide">
} <span class="qty">${item.quantity}</span>
<span class="name">${item.name || ''}</span>
<span class="price">${fmt(item.unit_price)}</span>
<span class="subtotal">${fmt(item.subtotal || itemTotal)}</span>
</div>`;
});
lines.push(''); const ticketHtml = `
lines.push(' Gracias por su compra!'); <div class="store-name">NEXUS AUTOPARTS</div>
lines.push(''); <div class="store-tagline">Tu conexion con las refacciones</div>
<div class="store-info">
Sucursal: ${currentRegister ? currentRegister.branch_name || '' : ''}<br>
RFC: NAU210315XX1
</div>
<hr class="divider-double">
<div class="folio-line">
<span>VENTA: V-${sale.id}</span>
<span>${dateStr}</span>
</div>
<div class="ticket-row" style="font-size: 9px; color: #555; margin-bottom: 4px;">
<span>Cliente: ${customerName}</span>
${customerRfc ? `<span>RFC: ${customerRfc}</span>` : ''}
</div>
<hr class="divider">
<div class="item-line-wide" style="font-weight: bold; font-size: 9px; color: #555; text-transform: uppercase;">
<span class="qty">Cant</span>
<span class="name">Descripcion</span>
<span class="price">P. Unit</span>
<span class="subtotal">Importe</span>
</div>
<hr class="divider" style="margin: 2px 0;">
${itemsHtml}
<hr class="divider-double">
<div class="total-section">
<div class="total-line">
<span>Subtotal:</span><span>${fmt(sale.subtotal)}</span>
</div>
${sale.discount_total > 0 ? `<div class="total-line"><span>Descuento:</span><span>-${fmt(sale.discount_total)}</span></div>` : ''}
<div class="total-line">
<span>IVA 16%:</span><span>${fmt(sale.tax_total)}</span>
</div>
<div class="total-line grand">
<span>TOTAL:</span><span>${fmt(sale.total)}</span>
</div>
</div>
<hr class="divider">
<div class="payment-section">
<div class="ticket-row">
<span>Forma de pago:</span><span>${sale.payment_method || paymentMethod}</span>
</div>
${sale.payment_method === 'efectivo' ? `
<div class="ticket-row">
<span>Recibido:</span><span>${fmt(sale.amount_paid)}</span>
</div>
<div class="ticket-row" style="font-weight: bold;">
<span>Cambio:</span><span>${fmt(sale.change_given || 0)}</span>
</div>` : ''}
</div>
<hr class="divider">
<div class="footer-section">
<div class="thanks">Gracias por su compra!</div>
<div>Conserve su ticket como comprobante.</div>
</div>
`;
document.getElementById('ticketPreview').textContent = lines.join('\n'); // Set both print area and preview
document.getElementById('ticketModal').classList.add('active'); const printArea = document.getElementById('ticketContent');
if (printArea) printArea.innerHTML = ticketHtml;
const preview = document.getElementById('ticketPreviewContent');
if (preview) preview.innerHTML = ticketHtml;
document.getElementById('ticketModal').classList.add('open');
} }
function closeTicketModal() { function closeTicketModal() {
document.getElementById('ticketModal').classList.remove('active'); document.getElementById('ticketModal').classList.remove('open');
} }
function printTicket() { function printTicket() {
// Make print area visible for @media print
const area = document.getElementById('ticketPrintArea');
if (area) area.style.display = 'block';
window.print(); window.print();
setTimeout(() => { if (area) area.style.display = 'none'; }, 500);
} }
// ─── Last Sale ─────────────────────── // ─── Last Sale ───────────────────────
async function showLastSale() { async function showLastSale() {
if (!lastSaleId) { alert('No hay venta reciente'); return; } if (!lastSaleId) { showToast('No hay venta reciente'); return; }
try { try {
const sale = await api(`/pos/api/sales/${lastSaleId}`); const sale = await api(`/pos/api/sales/${lastSaleId}`);
showTicket(sale); showTicket(sale);
@@ -804,15 +953,12 @@ const POS = (() => {
// ─── Drawer ────────────────────────── // ─── Drawer ──────────────────────────
function openDrawer() { function openDrawer() {
// Cash drawer open command (ESC/POS compatible) showToast('Comando enviado al cajon de efectivo.');
// In a real implementation, this would send the command to the printer
alert('Comando enviado al cajon de efectivo.');
} }
// ─── Keyboard Shortcuts ────────────── // ─── Keyboard Shortcuts ──────────────
function setupKeyboard() { function setupKeyboard() {
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
// Don't intercept when typing in inputs
const tag = e.target.tagName; const tag = e.target.tagName;
const inInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; const inInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
@@ -845,12 +991,20 @@ const POS = (() => {
break; break;
case 'Escape': case 'Escape':
e.preventDefault(); e.preventDefault();
if (document.getElementById('paymentModal').classList.contains('active')) { if (document.getElementById('paymentModal').classList.contains('open')) {
closePaymentModal(); closePaymentModal();
} else if (document.getElementById('newCustomerModal').classList.contains('active')) { } else if (document.getElementById('newCustomerModal').classList.contains('open')) {
closeNewCustomerModal(); closeNewCustomerModal();
} else if (document.getElementById('ticketModal').classList.contains('active')) { } else if (document.getElementById('ticketModal').classList.contains('open')) {
closeTicketModal(); closeTicketModal();
} else if (document.querySelector('.confirm-overlay.active')) {
// Close cancel modal
const overlay = document.getElementById('overlay-cancelar-venta');
const dialog = document.getElementById('modal-cancelar-venta');
if (overlay && overlay.classList.contains('active')) {
overlay.classList.remove('active');
dialog.classList.remove('active');
}
} else { } else {
hideSearchResults(); hideSearchResults();
} }

99
pos/static/js/reports.js Normal file
View File

@@ -0,0 +1,99 @@
// /home/Autopartes/pos/static/js/reports.js
// Reports module: sales reports, inventory reports, financial reports
const Reports = (() => {
function token() {
return localStorage.getItem('pos_token') || '';
}
function checkAuth() {
if (!token()) {
window.location.href = '/pos/login';
return false;
}
return true;
}
function headers() {
return { 'Authorization': `Bearer ${token()}`, 'Content-Type': 'application/json' };
}
function fmt(n) {
return parseFloat(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
// -------------------------------------------------------------------------
// Theme switcher
// -------------------------------------------------------------------------
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
try { localStorage.setItem('nexus-theme', theme); } catch(e) {}
var btnInd = document.getElementById('btn-industrial');
var btnMod = document.getElementById('btn-modern');
if (btnInd) btnInd.classList.toggle('is-active', theme === 'industrial');
if (btnMod) btnMod.classList.toggle('is-active', theme === 'modern');
}
window.setTheme = setTheme;
// -------------------------------------------------------------------------
// Tab switcher
// -------------------------------------------------------------------------
function switchTab(id, btn) {
document.querySelectorAll('.tab-panel').forEach(function(p) { p.classList.remove('is-active'); });
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('is-active'); });
var panel = document.getElementById('panel-' + id);
if (panel) panel.classList.add('is-active');
if (btn) btn.classList.add('is-active');
}
window.switchTab = switchTab;
// -------------------------------------------------------------------------
// Live clock
// -------------------------------------------------------------------------
function updateClock() {
var el = document.getElementById('live-clock');
if (!el) return;
var now = new Date();
var pad = function(n) { return String(n).padStart(2, '0'); };
el.textContent = pad(now.getHours()) + ':' + pad(now.getMinutes()) + ':' + pad(now.getSeconds());
}
// -------------------------------------------------------------------------
// Placeholder API calls
// -------------------------------------------------------------------------
async function loadSalesReport(params) {
// TODO: call /pos/api/cashregister/... with date range
}
async function loadInventoryReport() {
// TODO: call /pos/api/inventory/products for stock report
}
async function loadFinancialReport(params) {
// TODO: call /pos/api/accounting/... for financial reports
}
// -------------------------------------------------------------------------
// Init
// -------------------------------------------------------------------------
function init() {
if (!checkAuth()) return;
// Restore theme
try {
var saved = localStorage.getItem('nexus-theme') || 'industrial';
setTheme(saved);
} catch(e) {}
// Start clock
updateClock();
setInterval(updateClock, 1000);
}
document.addEventListener('DOMContentLoaded', init);
return {
init, setTheme, switchTab,
loadSalesReport, loadInventoryReport, loadFinancialReport, fmt
};
})();

File diff suppressed because it is too large Load Diff

View File

@@ -1388,6 +1388,101 @@
.sidebar-overlay.is-open { .sidebar-overlay.is-open {
display: block; display: block;
} }
/* =========================================================================
EMPTY STATE COMPONENT
========================================================================= */
.empty-state {
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
max-width: 320px;
padding: var(--space-8) var(--space-4);
margin: var(--space-10) auto;
width: 100%;
}
.empty-state.is-visible {
display: flex;
}
.empty-state__icon {
font-size: 48px;
line-height: 1;
margin-bottom: var(--space-4);
opacity: 0.6;
filter: grayscale(30%);
}
.empty-state__title {
font-family: var(--font-heading);
font-size: var(--text-h5);
font-weight: var(--heading-weight-secondary);
color: var(--color-text-primary);
margin-bottom: var(--space-2);
}
.empty-state__subtitle {
font-size: var(--text-body-sm);
color: var(--color-text-muted);
line-height: 1.5;
margin-bottom: var(--space-5);
}
.empty-state__action {
padding: var(--space-2) var(--space-5);
font-family: var(--font-body);
font-size: var(--text-body-sm);
font-weight: 500;
border-radius: var(--radius-md);
cursor: pointer;
transition: var(--transition-fast);
border: 1px solid var(--btn-secondary-border);
background: var(--btn-secondary-bg);
color: var(--btn-secondary-text);
}
.empty-state__action:hover {
background: var(--btn-secondary-bg-hover);
box-shadow: var(--shadow-sm);
}
/* =========================================================================
OFFLINE BANNER
========================================================================= */
@keyframes slideDown { from { transform: translateY(-100%); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
@keyframes slideUp { from { transform: translateY(0); opacity: 1; } to { transform: translateY(-100%); opacity: 0; } }
.banner {
display: flex; align-items: center; gap: var(--space-3);
padding: var(--space-3) var(--space-4); border-radius: var(--radius-md);
font-size: var(--text-body-sm); font-weight: 500; line-height: 1.4;
}
.banner--warning {
background: var(--color-warning-light, #fef9c3); color: var(--color-warning-dark, #854d0e);
border: 1px solid var(--color-warning, #eab308);
}
.banner--success {
background: var(--color-success-light, #dcfce7); color: var(--color-success-dark, #166534);
border: 1px solid var(--color-success, #22c55e);
}
.banner--error {
background: var(--color-error-light, #fef2f2); color: var(--color-error-dark, #991b1b);
border: 1px solid var(--color-error, #ef4444);
}
.banner--dismissing { animation: slideUp 0.3s ease-in forwards; }
.banner__icon { font-size: 18px; flex-shrink: 0; }
.banner__text { flex: 1; }
.banner__text strong { font-weight: 700; }
.banner__dismiss {
background: none; border: none; cursor: pointer; font-size: 18px;
padding: var(--space-1); opacity: 0.7; color: inherit;
}
.banner__dismiss:hover { opacity: 1; }
</style> </style>
</head> </head>
@@ -1871,6 +1966,14 @@
</div> </div>
<!-- /product-grid --> <!-- /product-grid -->
<!-- Empty State (shown when no results) -->
<div class="empty-state" id="emptyState">
<div class="empty-state__icon">&#128270;</div>
<div class="empty-state__title" id="emptyStateTitle">No se encontraron productos</div>
<div class="empty-state__subtitle" id="emptyStateSubtitle">Intenta con otro termino de busqueda o verifica el numero de parte</div>
<button class="empty-state__action" onclick="document.querySelector('.search-box input').value=''; document.querySelector('.search-box input').focus();">Limpiar filtros</button>
</div>
<!-- Pagination --> <!-- Pagination -->
<nav class="pagination" aria-label="Paginación de resultados"> <nav class="pagination" aria-label="Paginación de resultados">
<button class="page-item page-item--wide is-disabled" aria-label="Página anterior" disabled> <button class="page-item page-item--wide is-disabled" aria-label="Página anterior" disabled>
@@ -2069,5 +2172,13 @@
}); });
</script> </script>
<!-- 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;">
<span class="banner__icon"></span>
<span class="banner__text" id="offlineBannerText"><strong>Modo offline</strong> — Funciones limitadas. Solo consultas en cache disponibles.</span>
<button class="banner__dismiss" onclick="document.getElementById('offlineBanner').style.display='none'" aria-label="Cerrar">&times;</button>
</div>
<script src="/pos/static/js/offline-banner.js"></script>
</body> </body>
</html> </html>

1826
pos/templates/config.html Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1954
pos/templates/dashboard.html Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

2920
pos/templates/reports.html Normal file

File diff suppressed because it is too large Load Diff