582 lines
22 KiB
HTML
582 lines
22 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="es" data-theme="light">
|
|
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>SekiPOS - Caja</title>
|
|
<link rel="shortcut icon" href="./static/favicon.png" type="image/x-icon">
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
|
|
<style>
|
|
:root {
|
|
--bg: #ebedef;
|
|
--card-bg: #ffffff;
|
|
--text-main: #2e3338;
|
|
--text-muted: #4f5660;
|
|
--border: #e3e5e8;
|
|
--navbar-bg: #ffffff;
|
|
--accent: #5865f2;
|
|
--accent-hover: #4752c4;
|
|
--input-bg: #e3e5e8;
|
|
}
|
|
|
|
[data-theme="dark"] {
|
|
--bg: #36393f;
|
|
--card-bg: #2f3136;
|
|
--text-main: #dcddde;
|
|
--text-muted: #b9bbbe;
|
|
--border: #202225;
|
|
--navbar-bg: #202225;
|
|
--input-bg: #202225;
|
|
}
|
|
|
|
body {
|
|
background: var(--bg);
|
|
color: var(--text-main);
|
|
font-family: "gg sans", "Segoe UI", sans-serif;
|
|
transition: background 0.2s, color 0.2s;
|
|
min-height: 100vh;
|
|
}
|
|
|
|
/* ── Navbar ── */
|
|
.navbar {
|
|
background: var(--navbar-bg) !important;
|
|
border-bottom: 1px solid var(--border);
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
.navbar-brand {
|
|
color: var(--text-main) !important;
|
|
font-weight: 700;
|
|
}
|
|
|
|
/* ── Cards ── */
|
|
.cart-card,
|
|
.discord-card,
|
|
.modal-content {
|
|
background: var(--card-bg) !important;
|
|
color: var(--text-main);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
/* ── Product Preview (Matching Index) ── */
|
|
#display-img {
|
|
width: 100%;
|
|
max-width: 250px;
|
|
height: 250px;
|
|
object-fit: contain;
|
|
background: var(--bg);
|
|
border-radius: 8px;
|
|
padding: 10px;
|
|
}
|
|
|
|
/* ── Table Styling ── */
|
|
.table {
|
|
color: var(--text-main) !important;
|
|
--bs-table-bg: transparent;
|
|
--bs-table-border-color: var(--border);
|
|
}
|
|
|
|
.table thead th {
|
|
background: var(--bg);
|
|
color: var(--text-muted);
|
|
font-size: 0.75rem;
|
|
text-transform: uppercase;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
.table td {
|
|
border-bottom: 1px solid var(--border);
|
|
vertical-align: middle;
|
|
}
|
|
|
|
/* ── UI Elements ── */
|
|
.btn-accent {
|
|
background: var(--accent);
|
|
color: #fff;
|
|
border: none;
|
|
}
|
|
|
|
.btn-accent:hover {
|
|
background: var(--accent-hover);
|
|
color: #fff;
|
|
}
|
|
|
|
.form-control {
|
|
background: var(--input-bg);
|
|
color: var(--text-main);
|
|
border: none;
|
|
}
|
|
|
|
.form-control:focus {
|
|
background: var(--input-bg);
|
|
color: var(--text-main);
|
|
outline: 2px solid var(--accent);
|
|
box-shadow: none;
|
|
}
|
|
|
|
#grand-total {
|
|
color: var(--accent);
|
|
font-family: "gg sans", sans-serif;
|
|
}
|
|
|
|
.dropdown-menu {
|
|
background: var(--card-bg);
|
|
border: 1px solid var(--border);
|
|
}
|
|
|
|
.dropdown-item {
|
|
color: var(--text-main) !important;
|
|
}
|
|
|
|
.dropdown-item:hover {
|
|
background: var(--input-bg);
|
|
}
|
|
|
|
.btn-danger-discord {
|
|
background: var(--danger, #ed4245);
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: 4px;
|
|
padding: 4px 8px;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
.btn-danger-discord:hover {
|
|
background: #c23235;
|
|
color: #fff;
|
|
}
|
|
|
|
[data-theme="dark"] .btn-close {
|
|
filter: invert(1) grayscale(100%) brightness(200%);
|
|
}
|
|
|
|
[data-theme="dark"] .table {
|
|
--bs-table-color: var(--text-main);
|
|
color: var(--text-main) !important;
|
|
}
|
|
|
|
[data-theme="dark"] .table thead th {
|
|
background: #292b2f;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
[data-theme="dark"] .text-muted {
|
|
color: var(--text-muted) !important;
|
|
}
|
|
|
|
/* Fix for the weight modal text */
|
|
[data-theme="dark"] .modal-body {
|
|
color: var(--text-main);
|
|
}
|
|
|
|
input[type=number]::-webkit-inner-spin-button,
|
|
input[type=number]::-webkit-outer-spin-button {
|
|
-webkit-appearance: none;
|
|
margin: 0;
|
|
}
|
|
|
|
input[type=number] {
|
|
-moz-appearance: textfield;
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
<div class="modal fade" id="removeConfirmModal" tabindex="-1">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">Quitar Producto</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
¿Estás seguro de que quieres quitar <strong id="removeItemName"></strong> del carrito?
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
|
|
<button class="btn btn-danger-discord" id="btn-confirm-remove">Quitar</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal fade" id="clearCartModal" tabindex="-1">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">Vaciar Carrito</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body text-center">
|
|
<i class="bi bi-cart-x text-danger" style="font-size: 3rem;"></i>
|
|
<p class="mt-3">¿Seguro que quieres eliminar todos los productos del carrito?</p>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-secondary" data-bs-dismiss="modal">No, volver</button>
|
|
<button class="btn btn-danger-discord" onclick="executeClearCart()">Sí, vaciar</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal fade" id="successModal" tabindex="-1">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content border-success">
|
|
<div class="modal-body text-center py-4">
|
|
<i class="bi bi-check-circle-fill text-success" style="font-size: 4rem;"></i>
|
|
<h4 class="mt-3">¡Venta Exitosa!</h4>
|
|
<p class="text-muted">El carrito se ha procesado correctamente.</p>
|
|
<button class="btn btn-accent px-5" data-bs-dismiss="modal">Listo</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<nav class="navbar navbar-expand-md sticky-top px-3 mb-3">
|
|
<span class="navbar-brand">SekiPOS <small class="text-muted fw-normal"
|
|
style="font-size:0.65rem;">Caja</small></span>
|
|
|
|
<div class="ms-3">
|
|
<a href="/" class="btn btn-outline-primary btn-sm">
|
|
<i class="bi bi-box-seam me-1"></i>Inventario
|
|
</a>
|
|
</div>
|
|
|
|
<div class="ms-auto">
|
|
<div class="dropdown">
|
|
<button class="btn btn-accent dropdown-toggle" type="button" data-bs-toggle="dropdown"
|
|
aria-expanded="false">
|
|
<i class="bi bi-person-circle me-1"></i>
|
|
<span class="d-none d-sm-inline">{{ user.username }}</span>
|
|
</button>
|
|
<ul class="dropdown-menu dropdown-menu-end shadow">
|
|
<li>
|
|
<button class="dropdown-item" onclick="toggleTheme()">
|
|
<i class="bi bi-moon-stars me-2" id="theme-icon"></i>
|
|
<span id="theme-label">Modo Oscuro</span>
|
|
</button>
|
|
</li>
|
|
<li>
|
|
<hr class="dropdown-divider" style="border-color: var(--border);">
|
|
</li>
|
|
<li>
|
|
<a class="dropdown-item text-danger" href="/logout">
|
|
<i class="bi bi-box-arrow-right me-2"></i>Salir
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<div class="container-fluid">
|
|
<div class="row g-3">
|
|
<div class="col-md-8">
|
|
<div class="cart-card p-3 shadow-sm">
|
|
<h4><i class="bi bi-cart3"></i> Carrito</h4>
|
|
<table class="table mt-3" id="cart-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Código</th>
|
|
<th>Producto</th>
|
|
<th>Precio/U</th>
|
|
<th>Cant/Peso</th>
|
|
<th>Subtotal</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="cart-items">
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-md-4">
|
|
<div class="discord-card p-3 mb-3 text-center shadow-sm">
|
|
<p class="mb-1 fw-semibold text-uppercase" style="color:var(--text-muted); font-size:0.7rem;">Último
|
|
Escaneado</p>
|
|
<img id="display-img" src="./static/placeholder.png" class="mb-2" alt="product">
|
|
<h6 id="display-name" class="mb-0 text-truncate">Esperando scan...</h6>
|
|
<small id="display-barcode" class="text-muted font-monospace" style="font-size: 0.7rem;"></small>
|
|
</div>
|
|
|
|
<div class="total-banner text-center mb-3">
|
|
<div class="total-banner text-center mb-3">
|
|
<h2 class="mb-0">TOTAL</h2>
|
|
<h1 id="grand-total" style="font-size: 3.5rem; font-weight: 800;">$0</h1>
|
|
</div>
|
|
<button class="btn btn-success w-100 btn-lg mb-2" onclick="processSale()">
|
|
<i class="bi bi-cash-coin"></i> COBRAR
|
|
</button>
|
|
<button class="btn btn-danger w-100" onclick="clearCart()">Vaciar</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal fade" id="weightModal" tabindex="-1" data-bs-backdrop="static">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5>Ingresar Peso (kg)</h5>
|
|
</div>
|
|
<div class="modal-body">
|
|
<input type="number" id="weight-input" class="form-control form-control-lg" step="0.001"
|
|
placeholder="0.000">
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-primary w-100" onclick="confirmWeight()">Agregar</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const socket = io();
|
|
let cart = [];
|
|
let pendingProduct = null;
|
|
const clp = new Intl.NumberFormat('es-CL', { style: 'currency', currency: 'CLP', minimumFractionDigits: 0 });
|
|
|
|
socket.on('new_scan', (product) => {
|
|
// Update the Preview Card
|
|
document.getElementById('display-name').innerText = product.name;
|
|
document.getElementById('display-barcode').innerText = product.barcode;
|
|
document.getElementById('display-img').src = product.image || './static/placeholder.png';
|
|
|
|
if (product.unit === 'kg') {
|
|
pendingProduct = product;
|
|
new bootstrap.Modal('#weightModal').show();
|
|
setTimeout(() => document.getElementById('weight-input').focus(), 500);
|
|
} else {
|
|
addToCart(product, 1);
|
|
}
|
|
});
|
|
|
|
socket.on('scan_error', (data) => {
|
|
if (confirm("Producto no encontrado. ¿Deseas crearlo?")) {
|
|
window.location.href = `/?barcode=${data.barcode}`;
|
|
}
|
|
});
|
|
|
|
function confirmWeight() {
|
|
const weight = parseFloat(document.getElementById('weight-input').value);
|
|
if (weight > 0) {
|
|
addToCart(pendingProduct, weight);
|
|
bootstrap.Modal.getInstance('#weightModal').hide();
|
|
document.getElementById('weight-input').value = '';
|
|
}
|
|
}
|
|
|
|
function renderCart() {
|
|
const tbody = document.getElementById('cart-items');
|
|
tbody.innerHTML = '';
|
|
let total = 0;
|
|
|
|
cart.forEach((item, index) => {
|
|
total += item.subtotal;
|
|
const row = document.createElement('tr');
|
|
row.innerHTML = `
|
|
<td>${item.name}</td>
|
|
<td>${clp.format(item.price)}</td>
|
|
<td>${item.qty} ${item.unit === 'kg' ? 'kg' : 'u'}</td>
|
|
<td>${clp.format(item.subtotal)}</td>
|
|
<td>
|
|
<button class="btn-danger-discord btn-sm" onclick="removeItem(${index}, '${item.name}')">
|
|
<i class="bi bi-trash"></i>
|
|
</button>
|
|
</td>
|
|
`;
|
|
tbody.appendChild(row);
|
|
});
|
|
document.getElementById('grand-total').innerText = clp.format(total);
|
|
}
|
|
|
|
let itemIndexToRemove = null;
|
|
|
|
function removeItem(idx, name) {
|
|
itemIndexToRemove = idx;
|
|
document.getElementById('removeItemName').innerText = name;
|
|
const modal = new bootstrap.Modal(document.getElementById('removeConfirmModal'));
|
|
modal.show();
|
|
}
|
|
|
|
// Attach listener to the confirm button in the modal
|
|
document.getElementById('btn-confirm-remove').addEventListener('click', () => {
|
|
if (itemIndexToRemove !== null) {
|
|
cart.splice(itemIndexToRemove, 1);
|
|
renderCart();
|
|
bootstrap.Modal.getInstance(document.getElementById('removeConfirmModal')).hide();
|
|
itemIndexToRemove = null;
|
|
}
|
|
});
|
|
|
|
function clearCart() {
|
|
if (cart.length === 0) return;
|
|
const modal = new bootstrap.Modal(document.getElementById('clearCartModal'));
|
|
modal.show();
|
|
}
|
|
|
|
function executeClearCart() {
|
|
cart = [];
|
|
renderCart();
|
|
bootstrap.Modal.getInstance(document.getElementById('clearCartModal')).hide();
|
|
}
|
|
|
|
async function processSale() {
|
|
if (cart.length === 0) return;
|
|
|
|
const total = cart.reduce((sum, item) => sum + item.subtotal, 0);
|
|
|
|
// Disable button to prevent spam
|
|
const btn = document.querySelector('button[onclick="processSale()"]');
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Procesando...';
|
|
|
|
try {
|
|
alert("total: " total)
|
|
// const response = await fetch('/process_payment', {
|
|
// method: 'POST',
|
|
// headers: { 'Content-Type': 'application/json' },
|
|
// body: JSON.stringify({ total: total })
|
|
// });
|
|
|
|
// const result = await response.json();
|
|
|
|
// if (response.ok) {
|
|
// const modal = new bootstrap.Modal(document.getElementById('successModal'));
|
|
// modal.show();
|
|
// cart = [];
|
|
// renderCart();
|
|
// } else {
|
|
// alert("Error en el pago: " + (result.message || "Error desconocido"));
|
|
// }
|
|
} catch (err) {
|
|
alert("Error de conexión con el servidor.");
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.innerHTML = '<i class="bi bi-cash-coin"></i> COBRAR';
|
|
}
|
|
}
|
|
|
|
function confirmWeight() {
|
|
const weightInput = document.getElementById('weight-input');
|
|
const weight = parseFloat(weightInput.value);
|
|
if (weight > 0) {
|
|
// For weighted items, we usually append new entries or you can sum them.
|
|
// Here we append to allow different weighings of the same product type.
|
|
addToCart(pendingProduct, weight);
|
|
bootstrap.Modal.getInstance('#weightModal').hide();
|
|
weightInput.value = '';
|
|
}
|
|
}
|
|
|
|
function addToCart(product, qty) {
|
|
// Check if product (unit-based only) already exists in cart
|
|
const existingIndex = cart.findIndex(item => item.barcode === product.barcode && item.unit !== 'kg');
|
|
|
|
if (existingIndex !== -1) {
|
|
cart[existingIndex].qty += qty;
|
|
cart[existingIndex].subtotal = cart[existingIndex].qty * cart[existingIndex].price;
|
|
} else {
|
|
const subtotal = product.price * qty;
|
|
cart.push({ ...product, qty, subtotal });
|
|
}
|
|
renderCart();
|
|
}
|
|
|
|
function updateQty(index, delta) {
|
|
if (cart[index].unit === 'kg') return; // Delta buttons disabled for weighted items
|
|
|
|
cart[index].qty += delta;
|
|
if (cart[index].qty <= 0) {
|
|
removeItem(index, cart[index].name);
|
|
} else {
|
|
cart[index].subtotal = cart[index].qty * cart[index].price;
|
|
renderCart();
|
|
}
|
|
}
|
|
|
|
function manualQty(index, val) {
|
|
const newQty = parseFloat(val);
|
|
if (isNaN(newQty) || newQty <= 0) return;
|
|
|
|
cart[index].qty = newQty;
|
|
cart[index].subtotal = cart[index].qty * cart[index].price;
|
|
renderCart();
|
|
// Don't re-render immediately to avoid losing input focus while typing
|
|
}
|
|
|
|
function renderCart() {
|
|
const tbody = document.getElementById('cart-items');
|
|
tbody.innerHTML = '';
|
|
let total = 0;
|
|
|
|
cart.forEach((item, index) => {
|
|
total += item.subtotal;
|
|
const row = document.createElement('tr');
|
|
|
|
// Logic for quantity controls
|
|
let qtyControls;
|
|
if (item.unit === 'kg') {
|
|
qtyControls = `<span>${item.qty.toFixed(3)} kg</span>`;
|
|
} else {
|
|
qtyControls = `
|
|
<div class="d-flex align-items-center gap-1">
|
|
<button class="btn btn-sm btn-outline-secondary py-0 px-2" onclick="updateQty(${index}, -1)">-</button>
|
|
<input type="number" class="form-control form-control-sm text-center p-0"
|
|
style="width: 50px;" value="${item.qty}"
|
|
onchange="manualQty(${index}, this.value)"
|
|
onblur="renderCart()">
|
|
<button class="btn btn-sm btn-outline-secondary py-0 px-2" onclick="updateQty(${index}, 1)">+</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
row.innerHTML = `
|
|
<td class="font-monospace small text-muted">${item.barcode}</td>
|
|
<td>${item.name}</td>
|
|
<td>${clp.format(item.price)}</td>
|
|
<td>${qtyControls}</td>
|
|
<td>${clp.format(item.subtotal)}</td>
|
|
<td>
|
|
<button class="btn-danger-discord btn-sm" onclick="removeItem(${index}, '${item.name}')">
|
|
<i class="bi bi-trash"></i>
|
|
</button>
|
|
</td>
|
|
`;
|
|
tbody.appendChild(row);
|
|
});
|
|
document.getElementById('grand-total').innerText = clp.format(total);
|
|
}
|
|
|
|
function applyTheme(t) {
|
|
document.documentElement.setAttribute('data-theme', t);
|
|
const isDark = t === 'dark';
|
|
const themeIcon = document.getElementById('theme-icon');
|
|
const themeLabel = document.getElementById('theme-label');
|
|
|
|
if (themeIcon) themeIcon.className = isDark ? 'bi bi-sun me-2' : 'bi bi-moon-stars me-2';
|
|
if (themeLabel) themeLabel.innerText = isDark ? 'Modo Claro' : 'Modo Oscuro';
|
|
|
|
localStorage.setItem('theme', t);
|
|
}
|
|
|
|
function toggleTheme() {
|
|
const current = document.documentElement.getAttribute('data-theme');
|
|
const next = current === 'dark' ? 'light' : 'dark';
|
|
applyTheme(next);
|
|
}
|
|
|
|
// Initialize on load
|
|
(function initTheme() {
|
|
const savedTheme = localStorage.getItem('theme') || 'light';
|
|
applyTheme(savedTheme);
|
|
})();
|
|
</script>
|
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
|
</body>
|
|
|
|
</html> |