Compare commits
3 Commits
ccd3962458
...
0112b7dca4
| Author | SHA1 | Date | |
|---|---|---|---|
| 0112b7dca4 | |||
| 9641b0af80 | |||
| 21427c4dd2 |
12
pos/app.py
12
pos/app.py
@@ -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
139
pos/static/js/config.js
Normal 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
113
pos/static/js/dashboard.js
Normal 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 };
|
||||||
|
})();
|
||||||
46
pos/static/js/offline-banner.js
Normal file
46
pos/static/js/offline-banner.js
Normal 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);
|
||||||
|
})();
|
||||||
@@ -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})">×</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})">×</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, "'")}, ${price})'>
|
||||||
html += `<div class="search-result-item" onclick='POS.addFromSearch(${JSON.stringify(item).replace(/'/g, "'")}, ${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, "'")})'>
|
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, "'")})'>
|
||||||
<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
99
pos/static/js/reports.js
Normal 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
@@ -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">🔎</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">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/pos/static/js/offline-banner.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
1826
pos/templates/config.html
Normal file
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
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
2920
pos/templates/reports.html
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user