Files
2026-05-16 23:00:37 -05:00

1387 lines
44 KiB
PHP

<?php
/**
* Tom's Java Jive - Admin POS (Point of Sale) - Enhanced
*/
$pageTitle = 'Point of Sale';
require_once __DIR__ . '/includes/header.php';
// Get all active products
$products = db()->fetchAll(
"SELECT product_id, name, price, sale_price, stock, images, category, barcode, sku
FROM products WHERE is_active = 1 ORDER BY category, name"
);
// Group products by category
$productsByCategory = [];
foreach ($products as $product) {
$cat = $product['category'] ?? 'Other';
if (!isset($productsByCategory[$cat])) {
$productsByCategory[$cat] = [];
}
$product['images'] = json_decode($product['images'] ?? '[]', true);
$product['display_price'] = $product['sale_price'] ?? $product['price'];
$productsByCategory[$cat][] = $product;
}
// Get held orders
$heldOrders = $_SESSION['pos_held_orders'] ?? [];
// Get tax rate from settings
$taxRate = getSetting('tax_rate', 0) / 100;
?>
<style>
.pos-layout {
display: grid;
grid-template-columns: 1fr 400px;
gap: 1rem;
height: calc(100vh - 120px);
}
.pos-products {
background: var(--admin-surface);
border-radius: var(--admin-radius);
overflow: hidden;
display: flex;
flex-direction: column;
}
.pos-search {
padding: 1rem;
border-bottom: 1px solid var(--admin-border);
display: flex;
gap: 0.5rem;
}
.pos-search input {
flex: 1;
padding: 0.75rem 1rem;
border: 1px solid var(--admin-border);
border-radius: var(--admin-radius);
font-size: 1rem;
}
.pos-search .btn {
padding: 0.75rem;
}
.pos-categories {
display: flex;
gap: 0.5rem;
padding: 1rem;
border-bottom: 1px solid var(--admin-border);
overflow-x: auto;
flex-wrap: nowrap;
}
.pos-category-btn {
padding: 0.5rem 1rem;
border: 1px solid var(--admin-border);
border-radius: 20px;
background: var(--admin-surface);
cursor: pointer;
white-space: nowrap;
font-size: 0.875rem;
transition: all 0.2s;
}
.pos-category-btn:hover {
background: var(--admin-bg);
}
.pos-category-btn.active {
background: var(--admin-primary);
border-color: var(--admin-primary);
color: white;
}
.pos-product-grid {
flex: 1;
padding: 1rem;
overflow-y: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 0.75rem;
align-content: start;
}
.pos-product-item {
background: var(--admin-bg);
border-radius: var(--admin-radius);
padding: 0.75rem;
cursor: pointer;
text-align: center;
transition: all 0.2s;
border: 2px solid transparent;
}
.pos-product-item:hover {
border-color: var(--admin-primary);
transform: translateY(-2px);
}
.pos-product-item.out-of-stock {
opacity: 0.5;
cursor: not-allowed;
}
.pos-product-item img {
width: 70px;
height: 70px;
object-fit: cover;
border-radius: var(--admin-radius);
margin-bottom: 0.5rem;
}
.pos-product-item .name {
font-size: 0.8rem;
font-weight: 500;
margin-bottom: 0.25rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.2;
min-height: 2.4em;
}
.pos-product-item .price {
color: var(--admin-primary);
font-weight: 700;
font-size: 0.9rem;
}
.pos-product-item .stock {
font-size: 0.7rem;
color: var(--admin-text-muted);
margin-top: 0.25rem;
}
.pos-product-item .stock.low {
color: var(--admin-warning);
}
.pos-product-item .stock.out {
color: var(--admin-error);
}
/* Cart Section */
.pos-cart {
background: var(--admin-surface);
border-radius: var(--admin-radius);
display: flex;
flex-direction: column;
}
.pos-cart-header {
padding: 1rem;
border-bottom: 1px solid var(--admin-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.pos-cart-header h3 {
margin: 0;
font-size: 1rem;
}
.pos-customer-badge {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.75rem;
background: var(--admin-bg);
border-radius: 20px;
font-size: 0.8rem;
}
.pos-customer-badge .remove {
cursor: pointer;
opacity: 0.5;
}
.pos-customer-badge .remove:hover {
opacity: 1;
}
.pos-cart-items {
flex: 1;
overflow-y: auto;
padding: 0.5rem 1rem;
min-height: 150px;
}
.pos-cart-item {
display: flex;
gap: 0.75rem;
padding: 0.75rem 0;
border-bottom: 1px solid var(--admin-border);
align-items: center;
}
.pos-cart-item:last-child {
border-bottom: none;
}
.pos-cart-item-info {
flex: 1;
min-width: 0;
}
.pos-cart-item-name {
font-weight: 500;
font-size: 0.875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pos-cart-item-price {
color: var(--admin-text-muted);
font-size: 0.8rem;
}
.pos-cart-item-qty {
display: flex;
align-items: center;
gap: 0.25rem;
}
.pos-cart-item-qty button {
width: 26px;
height: 26px;
border: 1px solid var(--admin-border);
border-radius: 4px;
background: var(--admin-surface);
cursor: pointer;
font-size: 0.875rem;
display: flex;
align-items: center;
justify-content: center;
}
.pos-cart-item-qty button:hover {
background: var(--admin-bg);
}
.pos-cart-item-qty span {
width: 30px;
text-align: center;
font-weight: 500;
}
.pos-cart-item-total {
font-weight: 600;
min-width: 60px;
text-align: right;
font-size: 0.9rem;
}
.pos-cart-item-remove {
color: var(--admin-error);
cursor: pointer;
opacity: 0.5;
font-size: 0.875rem;
}
.pos-cart-item-remove:hover {
opacity: 1;
}
/* Discount Section */
.pos-discount-section {
padding: 0.75rem 1rem;
border-top: 1px solid var(--admin-border);
background: var(--admin-bg);
}
.pos-discount-row {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.pos-discount-row input {
flex: 1;
padding: 0.5rem;
border: 1px solid var(--admin-border);
border-radius: var(--admin-radius);
font-size: 0.875rem;
}
.pos-applied-discount {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
background: rgba(16, 185, 129, 0.1);
border-radius: var(--admin-radius);
font-size: 0.8rem;
}
.pos-applied-discount .remove {
cursor: pointer;
color: var(--admin-error);
}
/* Totals */
.pos-cart-totals {
padding: 1rem;
border-top: 1px solid var(--admin-border);
background: var(--admin-bg);
}
.pos-cart-row {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
.pos-cart-row.discount {
color: var(--admin-success);
}
.pos-cart-row.total {
font-size: 1.25rem;
font-weight: 700;
border-top: 2px solid var(--admin-border);
padding-top: 0.75rem;
margin-top: 0.5rem;
margin-bottom: 0;
}
.pos-cart-actions {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.pos-cart-actions .btn-primary {
padding: 1rem;
font-size: 1rem;
}
.pos-empty {
text-align: center;
padding: 2rem;
color: var(--admin-text-muted);
}
.pos-empty i {
font-size: 2.5rem;
margin-bottom: 0.75rem;
opacity: 0.5;
}
/* Held Orders Badge */
.held-orders-badge {
position: relative;
}
.held-orders-badge .count {
position: absolute;
top: -5px;
right: -5px;
background: var(--admin-error);
color: white;
font-size: 0.7rem;
width: 18px;
height: 18px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
/* Receipt Modal */
.receipt {
font-family: 'Courier New', monospace;
font-size: 12px;
max-width: 280px;
margin: 0 auto;
padding: 20px;
background: white;
color: black;
}
.receipt-header {
text-align: center;
margin-bottom: 15px;
border-bottom: 1px dashed #000;
padding-bottom: 10px;
}
.receipt-header h2 {
margin: 0 0 5px;
font-size: 16px;
}
.receipt-items {
margin-bottom: 15px;
}
.receipt-item {
display: flex;
justify-content: space-between;
margin-bottom: 5px;
}
.receipt-totals {
border-top: 1px dashed #000;
padding-top: 10px;
}
.receipt-total-row {
display: flex;
justify-content: space-between;
margin-bottom: 3px;
}
.receipt-total-row.grand-total {
font-weight: bold;
font-size: 14px;
border-top: 1px solid #000;
padding-top: 5px;
margin-top: 5px;
}
.receipt-footer {
text-align: center;
margin-top: 15px;
border-top: 1px dashed #000;
padding-top: 10px;
font-size: 11px;
}
@media print {
body * {
visibility: hidden;
}
#receiptContent, #receiptContent * {
visibility: visible;
}
#receiptContent {
position: absolute;
left: 0;
top: 0;
}
}
</style>
<div class="pos-layout">
<!-- Products Section -->
<div class="pos-products">
<div class="pos-search">
<input type="text" id="pos-search" placeholder="Search products or scan barcode..." autofocus>
<button class="btn btn-secondary" onclick="document.getElementById('pos-search').value=''; filterProducts();" title="Clear">
<i class="fas fa-times"></i>
</button>
</div>
<div class="pos-categories">
<button class="pos-category-btn active" data-category="all">All</button>
<?php foreach (array_keys($productsByCategory) as $category): ?>
<button class="pos-category-btn" data-category="<?= htmlspecialchars($category) ?>">
<?= htmlspecialchars(ucfirst($category)) ?>
</button>
<?php endforeach; ?>
</div>
<div class="pos-product-grid" id="product-grid">
<?php if (empty($products)): ?>
<div style="grid-column: 1 / -1; text-align: center; padding: 3rem; color: var(--admin-text-muted);">
<i class="fas fa-box-open" style="font-size: 3rem; margin-bottom: 1rem; opacity: 0.5;"></i>
<p>No products available</p>
<a href="/admin/product-edit.php" class="btn btn-primary mt-1">Add Products</a>
</div>
<?php else: ?>
<?php foreach ($products as $product):
$images = json_decode($product['images'] ?? '[]', true);
$imageUrl = !empty($images) ? $images[0] : '/assets/images/placeholder-product.svg';
$isOutOfStock = $product['stock'] <= 0;
$isLowStock = $product['stock'] > 0 && $product['stock'] <= 5;
?>
<div class="pos-product-item <?= $isOutOfStock ? 'out-of-stock' : '' ?>"
data-id="<?= $product['product_id'] ?>"
data-name="<?= htmlspecialchars($product['name']) ?>"
data-price="<?= $product['sale_price'] ?? $product['price'] ?>"
data-stock="<?= $product['stock'] ?>"
data-category="<?= htmlspecialchars($product['category'] ?? 'Other') ?>"
data-barcode="<?= htmlspecialchars($product['barcode'] ?? '') ?>"
data-sku="<?= htmlspecialchars($product['sku'] ?? '') ?>">
<img src="<?= htmlspecialchars($imageUrl) ?>" alt="" onerror="this.src='/assets/images/placeholder-product.svg'">
<div class="name"><?= htmlspecialchars($product['name']) ?></div>
<div class="price"><?= formatCurrency($product['sale_price'] ?? $product['price']) ?></div>
<div class="stock <?= $isOutOfStock ? 'out' : ($isLowStock ? 'low' : '') ?>">
<?= $isOutOfStock ? 'Out of stock' : $product['stock'] . ' in stock' ?>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
<!-- Cart Section -->
<div class="pos-cart">
<div class="pos-cart-header">
<h3><i class="fas fa-shopping-cart"></i> Current Sale</h3>
<div id="customer-display">
<button class="btn btn-sm btn-secondary" onclick="openCustomerModal()">
<i class="fas fa-user-plus"></i> Customer
</button>
</div>
</div>
<div class="pos-cart-items" id="cart-items">
<div class="pos-empty" id="cart-empty">
<i class="fas fa-shopping-basket"></i>
<p>Add products to start a sale</p>
</div>
</div>
<!-- Discount Section -->
<div class="pos-discount-section" id="discount-section">
<div class="pos-discount-row">
<input type="text" id="coupon-input" placeholder="Coupon code" style="text-transform: uppercase;">
<button class="btn btn-sm btn-secondary" onclick="applyCoupon()">Apply</button>
</div>
<div class="pos-discount-row">
<input type="number" id="manual-discount" placeholder="Manual discount $" step="0.01" min="0">
<button class="btn btn-sm btn-secondary" onclick="applyManualDiscount()">Apply</button>
</div>
<div id="applied-discounts"></div>
</div>
<div class="pos-cart-totals">
<div class="pos-cart-row">
<span>Subtotal</span>
<span id="cart-subtotal">$0.00</span>
</div>
<div class="pos-cart-row discount" id="discount-row" style="display: none;">
<span>Discount</span>
<span id="cart-discount">-$0.00</span>
</div>
<div class="pos-cart-row">
<span>Tax (<?= ($taxRate * 100) ?>%)</span>
<span id="cart-tax">$0.00</span>
</div>
<div class="pos-cart-row total">
<span>Total</span>
<span id="cart-total">$0.00</span>
</div>
</div>
<div class="pos-cart-actions">
<button class="btn btn-primary" id="btn-checkout" disabled>
<i class="fas fa-credit-card"></i> Pay Now
</button>
<div style="display: flex; gap: 0.5rem;">
<button class="btn btn-secondary held-orders-badge" id="btn-held" style="flex: 1;" onclick="openHeldOrdersModal()">
<i class="fas fa-pause"></i> Held
<span class="count" id="held-count" style="display: none;">0</span>
</button>
<button class="btn btn-secondary" id="btn-hold" style="flex: 1;" disabled onclick="holdOrder()">
<i class="fas fa-clock"></i> Hold
</button>
<button class="btn btn-danger" id="btn-clear" style="flex: 1;" disabled onclick="clearCart()">
<i class="fas fa-trash"></i> Clear
</button>
</div>
</div>
</div>
</div>
<!-- Payment Modal -->
<div class="modal-overlay" id="paymentModal">
<div class="modal" style="max-width: 450px;">
<div class="modal-header">
<h3 class="modal-title">Process Payment</h3>
<button type="button" class="modal-close" onclick="Modal.close('paymentModal')">&times;</button>
</div>
<div class="modal-body">
<div style="text-align: center; margin-bottom: 1.5rem; padding: 1rem; background: var(--admin-bg); border-radius: var(--admin-radius);">
<div style="font-size: 0.9rem; color: var(--admin-text-muted);">Amount Due</div>
<div style="font-size: 2rem; font-weight: 700; color: var(--admin-primary);" id="payment-amount">$0.00</div>
</div>
<div class="form-group">
<label class="form-label">Payment Method</label>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.5rem;">
<button type="button" class="btn btn-secondary payment-method-btn active" data-method="cash">
<i class="fas fa-money-bill-wave"></i><br>Cash
</button>
<button type="button" class="btn btn-secondary payment-method-btn" data-method="card">
<i class="fas fa-credit-card"></i><br>Card
</button>
<button type="button" class="btn btn-secondary payment-method-btn" data-method="wallet" id="wallet-btn" style="display: none;">
<i class="fas fa-wallet"></i><br>Wallet
</button>
</div>
</div>
<div id="cash-fields">
<div class="form-group">
<label class="form-label">Amount Received</label>
<input type="number" id="cash-received" class="form-input" step="0.01" min="0" style="font-size: 1.25rem; text-align: center;">
</div>
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.5rem; margin-bottom: 1rem;">
<button type="button" class="btn btn-secondary quick-cash" data-amount="5">$5</button>
<button type="button" class="btn btn-secondary quick-cash" data-amount="10">$10</button>
<button type="button" class="btn btn-secondary quick-cash" data-amount="20">$20</button>
<button type="button" class="btn btn-secondary quick-cash" data-amount="50">$50</button>
</div>
<div class="form-group">
<label class="form-label">Change Due</label>
<input type="text" id="change-due" class="form-input" readonly value="$0.00" style="font-size: 1.25rem; text-align: center; font-weight: 700; color: var(--admin-success);">
</div>
</div>
<div id="wallet-info" style="display: none; padding: 1rem; background: var(--admin-bg); border-radius: var(--admin-radius); margin-bottom: 1rem;">
<div style="display: flex; justify-content: space-between;">
<span>Wallet Balance:</span>
<strong id="wallet-balance">$0.00</strong>
</div>
</div>
<div class="form-group">
<label class="form-label">Order Notes</label>
<input type="text" id="order-notes" class="form-input" placeholder="Any special notes...">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="Modal.close('paymentModal')">Cancel</button>
<button type="button" class="btn btn-primary btn-lg" id="btn-complete-payment" style="min-width: 150px;">
<i class="fas fa-check"></i> Complete Sale
</button>
</div>
</div>
</div>
<!-- Customer Search Modal -->
<div class="modal-overlay" id="customerModal">
<div class="modal" style="max-width: 500px;">
<div class="modal-header">
<h3 class="modal-title">Select Customer</h3>
<button type="button" class="modal-close" onclick="Modal.close('customerModal')">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<input type="text" id="customer-search-input" class="form-input" placeholder="Search by name, email, or phone..." oninput="searchCustomers(this.value)">
</div>
<div id="customer-results" style="max-height: 300px; overflow-y: auto;">
<p class="text-muted text-center">Type to search customers</p>
</div>
<hr>
<p class="text-center">
<button class="btn btn-secondary" onclick="Modal.close('customerModal')">Continue as Guest</button>
</p>
</div>
</div>
</div>
<!-- Held Orders Modal -->
<div class="modal-overlay" id="heldOrdersModal">
<div class="modal" style="max-width: 500px;">
<div class="modal-header">
<h3 class="modal-title">Held Orders</h3>
<button type="button" class="modal-close" onclick="Modal.close('heldOrdersModal')">&times;</button>
</div>
<div class="modal-body">
<div id="held-orders-list">
<p class="text-muted text-center">No held orders</p>
</div>
</div>
</div>
</div>
<!-- Receipt Modal -->
<div class="modal-overlay" id="receiptModal">
<div class="modal" style="max-width: 350px;">
<div class="modal-header">
<h3 class="modal-title">Receipt</h3>
<button type="button" class="modal-close" onclick="Modal.close('receiptModal')">&times;</button>
</div>
<div class="modal-body" style="padding: 0;">
<div id="receiptContent"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="Modal.close('receiptModal')">Close</button>
<button type="button" class="btn btn-primary" onclick="printReceipt()">
<i class="fas fa-print"></i> Print
</button>
</div>
</div>
</div>
<script>
// POS State
const POS = {
cart: [],
customer: null,
coupon: null,
manualDiscount: 0,
taxRate: <?= $taxRate ?>,
heldOrders: JSON.parse(localStorage.getItem('pos_held_orders') || '[]'),
paymentMethod: 'cash'
};
// DOM Elements
const cartItemsEl = document.getElementById('cart-items');
const cartEmptyEl = document.getElementById('cart-empty');
const searchEl = document.getElementById('pos-search');
// Add product to cart
function addToCart(product) {
if (product.stock <= 0) {
AdminToast.error('Product is out of stock');
return;
}
const existing = POS.cart.find(item => item.id === product.id);
if (existing) {
if (existing.quantity < product.stock) {
existing.quantity++;
} else {
AdminToast.error('Maximum stock reached');
return;
}
} else {
POS.cart.push({
id: product.id,
name: product.name,
price: parseFloat(product.price),
quantity: 1,
stock: parseInt(product.stock)
});
}
renderCart();
}
// Update quantity
function updateQuantity(id, delta) {
const item = POS.cart.find(i => i.id === id);
if (!item) return;
const newQty = item.quantity + delta;
if (newQty <= 0) {
removeFromCart(id);
} else if (newQty <= item.stock) {
item.quantity = newQty;
renderCart();
} else {
AdminToast.error('Maximum stock reached');
}
}
// Remove from cart
function removeFromCart(id) {
POS.cart = POS.cart.filter(i => i.id !== id);
renderCart();
}
// Calculate totals
function calculateTotals() {
const subtotal = POS.cart.reduce((sum, item) => sum + (item.price * item.quantity), 0);
let discount = POS.manualDiscount;
if (POS.coupon) {
if (POS.coupon.type === 'percentage') {
discount += subtotal * (POS.coupon.value / 100);
} else {
discount += Math.min(POS.coupon.value, subtotal);
}
}
const taxable = Math.max(0, subtotal - discount);
const tax = taxable * POS.taxRate;
const total = taxable + tax;
return { subtotal, discount, tax, total };
}
// Render cart
function renderCart() {
const { subtotal, discount, tax, total } = calculateTotals();
if (POS.cart.length === 0) {
cartEmptyEl.style.display = 'block';
cartItemsEl.querySelectorAll('.pos-cart-item').forEach(el => el.remove());
} else {
cartEmptyEl.style.display = 'none';
let html = '';
POS.cart.forEach(item => {
html += `
<div class="pos-cart-item">
<div class="pos-cart-item-info">
<div class="pos-cart-item-name">${escapeHtml(item.name)}</div>
<div class="pos-cart-item-price">$${item.price.toFixed(2)} each</div>
</div>
<div class="pos-cart-item-qty">
<button onclick="updateQuantity('${item.id}', -1)">-</button>
<span>${item.quantity}</span>
<button onclick="updateQuantity('${item.id}', 1)">+</button>
</div>
<div class="pos-cart-item-total">$${(item.price * item.quantity).toFixed(2)}</div>
<i class="fas fa-times pos-cart-item-remove" onclick="removeFromCart('${item.id}')"></i>
</div>
`;
});
cartItemsEl.querySelectorAll('.pos-cart-item').forEach(el => el.remove());
cartItemsEl.insertAdjacentHTML('beforeend', html);
}
document.getElementById('cart-subtotal').textContent = '$' + subtotal.toFixed(2);
document.getElementById('cart-discount').textContent = '-$' + discount.toFixed(2);
document.getElementById('discount-row').style.display = discount > 0 ? 'flex' : 'none';
document.getElementById('cart-tax').textContent = '$' + tax.toFixed(2);
document.getElementById('cart-total').textContent = '$' + total.toFixed(2);
const hasItems = POS.cart.length > 0;
document.getElementById('btn-checkout').disabled = !hasItems;
document.getElementById('btn-hold').disabled = !hasItems;
document.getElementById('btn-clear').disabled = !hasItems;
updateHeldCount();
}
// Filter products
function filterProducts() {
const query = searchEl.value.toLowerCase();
const activeCategory = document.querySelector('.pos-category-btn.active')?.dataset.category || 'all';
document.querySelectorAll('.pos-product-item').forEach(item => {
const name = item.dataset.name.toLowerCase();
const barcode = (item.dataset.barcode || '').toLowerCase();
const sku = (item.dataset.sku || '').toLowerCase();
const category = item.dataset.category;
const matchesSearch = !query || name.includes(query) || barcode.includes(query) || sku.includes(query);
const matchesCategory = activeCategory === 'all' || category === activeCategory;
item.style.display = (matchesSearch && matchesCategory) ? '' : 'none';
});
}
// Product click handlers
document.querySelectorAll('.pos-product-item').forEach(el => {
el.addEventListener('click', function() {
if (this.classList.contains('out-of-stock')) return;
addToCart({
id: this.dataset.id,
name: this.dataset.name,
price: this.dataset.price,
stock: parseInt(this.dataset.stock)
});
});
});
// Category filter
document.querySelectorAll('.pos-category-btn').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('.pos-category-btn').forEach(b => b.classList.remove('active'));
this.classList.add('active');
filterProducts();
});
});
// Search
searchEl.addEventListener('input', filterProducts);
// Barcode scanner - detect quick input
let barcodeBuffer = '';
let barcodeTimeout;
searchEl.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
const barcode = this.value.trim();
if (barcode) {
const product = document.querySelector(`.pos-product-item[data-barcode="${barcode}"]`) ||
document.querySelector(`.pos-product-item[data-sku="${barcode}"]`);
if (product && !product.classList.contains('out-of-stock')) {
product.click();
this.value = '';
AdminToast.success('Product added');
}
}
}
});
// Clear cart
function clearCart() {
if (POS.cart.length === 0) return;
if (confirm('Clear the entire cart?')) {
POS.cart = [];
POS.coupon = null;
POS.manualDiscount = 0;
document.getElementById('coupon-input').value = '';
document.getElementById('manual-discount').value = '';
document.getElementById('applied-discounts').innerHTML = '';
renderCart();
}
}
// Apply coupon
async function applyCoupon() {
const code = document.getElementById('coupon-input').value.trim().toUpperCase();
if (!code) return;
try {
const response = await fetch('/api/validate-coupon.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, subtotal: calculateTotals().subtotal })
});
const data = await response.json();
if (data.error) {
AdminToast.error(data.error);
} else {
POS.coupon = {
code: code,
type: data.type,
value: data.value
};
renderAppliedDiscounts();
renderCart();
AdminToast.success('Coupon applied');
}
} catch (err) {
AdminToast.error('Failed to validate coupon');
}
}
// Apply manual discount
function applyManualDiscount() {
const amount = parseFloat(document.getElementById('manual-discount').value) || 0;
if (amount <= 0) return;
POS.manualDiscount = amount;
renderAppliedDiscounts();
renderCart();
}
// Render applied discounts
function renderAppliedDiscounts() {
let html = '';
if (POS.coupon) {
const display = POS.coupon.type === 'percentage'
? `${POS.coupon.value}% off`
: `$${POS.coupon.value.toFixed(2)} off`;
html += `
<div class="pos-applied-discount">
<span><i class="fas fa-tag"></i> ${POS.coupon.code}: ${display}</span>
<i class="fas fa-times remove" onclick="removeCoupon()"></i>
</div>
`;
}
if (POS.manualDiscount > 0) {
html += `
<div class="pos-applied-discount" style="margin-top: 0.5rem;">
<span><i class="fas fa-percent"></i> Manual: -$${POS.manualDiscount.toFixed(2)}</span>
<i class="fas fa-times remove" onclick="removeManualDiscount()"></i>
</div>
`;
}
document.getElementById('applied-discounts').innerHTML = html;
}
function removeCoupon() {
POS.coupon = null;
document.getElementById('coupon-input').value = '';
renderAppliedDiscounts();
renderCart();
}
function removeManualDiscount() {
POS.manualDiscount = 0;
document.getElementById('manual-discount').value = '';
renderAppliedDiscounts();
renderCart();
}
// Customer search
async function searchCustomers(query) {
if (query.length < 2) {
document.getElementById('customer-results').innerHTML = '<p class="text-muted text-center">Type to search customers</p>';
return;
}
try {
const response = await fetch('/api/search-customers.php?q=' + encodeURIComponent(query));
const data = await response.json();
if (data.length === 0) {
document.getElementById('customer-results').innerHTML = '<p class="text-muted text-center">No customers found</p>';
} else {
let html = '';
data.forEach(c => {
html += `
<div style="padding: 0.75rem; border-bottom: 1px solid var(--admin-border); cursor: pointer; display: flex; justify-content: space-between; align-items: center;"
onclick="selectCustomer(${JSON.stringify(c).replace(/"/g, '&quot;')})">
<div>
<strong>${escapeHtml(c.name || c.email)}</strong><br>
<small class="text-muted">${escapeHtml(c.email)}${c.phone ? ' • ' + escapeHtml(c.phone) : ''}</small>
</div>
<div style="text-align: right;">
<div style="color: var(--admin-success);">$${parseFloat(c.wallet_balance || 0).toFixed(2)}</div>
<small class="text-muted">${c.reward_points || 0} pts</small>
</div>
</div>
`;
});
document.getElementById('customer-results').innerHTML = html;
}
} catch (err) {
document.getElementById('customer-results').innerHTML = '<p class="text-muted text-center">Search failed</p>';
}
}
function selectCustomer(customer) {
POS.customer = customer;
document.getElementById('customer-display').innerHTML = `
<div class="pos-customer-badge">
<i class="fas fa-user"></i>
<span>${escapeHtml(customer.name || customer.email)}</span>
<span class="remove" onclick="removeCustomer(); event.stopPropagation();">&times;</span>
</div>
`;
Modal.close('customerModal');
// Show wallet payment option
if (parseFloat(customer.wallet_balance) > 0) {
document.getElementById('wallet-btn').style.display = '';
}
}
function removeCustomer() {
POS.customer = null;
document.getElementById('customer-display').innerHTML = `
<button class="btn btn-sm btn-secondary" onclick="openCustomerModal()">
<i class="fas fa-user-plus"></i> Customer
</button>
`;
document.getElementById('wallet-btn').style.display = 'none';
}
function openCustomerModal() {
document.getElementById('customer-search-input').value = '';
document.getElementById('customer-results').innerHTML = '<p class="text-muted text-center">Type to search customers</p>';
Modal.open('customerModal');
setTimeout(() => document.getElementById('customer-search-input').focus(), 100);
}
// Hold order
function holdOrder() {
if (POS.cart.length === 0) return;
const heldOrder = {
id: Date.now(),
cart: [...POS.cart],
customer: POS.customer,
coupon: POS.coupon,
manualDiscount: POS.manualDiscount,
timestamp: new Date().toISOString()
};
POS.heldOrders.push(heldOrder);
localStorage.setItem('pos_held_orders', JSON.stringify(POS.heldOrders));
// Clear current
POS.cart = [];
POS.coupon = null;
POS.manualDiscount = 0;
removeCustomer();
document.getElementById('coupon-input').value = '';
document.getElementById('manual-discount').value = '';
document.getElementById('applied-discounts').innerHTML = '';
renderCart();
AdminToast.success('Order held');
}
// Recall held order
function recallHeldOrder(id) {
const index = POS.heldOrders.findIndex(o => o.id === id);
if (index === -1) return;
const order = POS.heldOrders[index];
// Check if current cart has items
if (POS.cart.length > 0) {
if (!confirm('This will replace your current cart. Continue?')) return;
}
POS.cart = order.cart;
POS.coupon = order.coupon;
POS.manualDiscount = order.manualDiscount;
if (order.customer) selectCustomer(order.customer);
// Remove from held
POS.heldOrders.splice(index, 1);
localStorage.setItem('pos_held_orders', JSON.stringify(POS.heldOrders));
renderAppliedDiscounts();
renderCart();
Modal.close('heldOrdersModal');
AdminToast.success('Order recalled');
}
function deleteHeldOrder(id) {
POS.heldOrders = POS.heldOrders.filter(o => o.id !== id);
localStorage.setItem('pos_held_orders', JSON.stringify(POS.heldOrders));
openHeldOrdersModal();
updateHeldCount();
}
function openHeldOrdersModal() {
if (POS.heldOrders.length === 0) {
document.getElementById('held-orders-list').innerHTML = '<p class="text-muted text-center">No held orders</p>';
} else {
let html = '';
POS.heldOrders.forEach(order => {
const total = order.cart.reduce((sum, i) => sum + (i.price * i.quantity), 0);
const time = new Date(order.timestamp).toLocaleTimeString();
html += `
<div style="padding: 1rem; border-bottom: 1px solid var(--admin-border); display: flex; justify-content: space-between; align-items: center;">
<div>
<strong>${order.customer ? escapeHtml(order.customer.name || order.customer.email) : 'Guest'}</strong><br>
<small class="text-muted">${order.cart.length} items • $${total.toFixed(2)} • ${time}</small>
</div>
<div style="display: flex; gap: 0.5rem;">
<button class="btn btn-sm btn-primary" onclick="recallHeldOrder(${order.id})">Recall</button>
<button class="btn btn-sm btn-danger" onclick="deleteHeldOrder(${order.id})"><i class="fas fa-trash"></i></button>
</div>
</div>
`;
});
document.getElementById('held-orders-list').innerHTML = html;
}
Modal.open('heldOrdersModal');
}
function updateHeldCount() {
const count = POS.heldOrders.length;
const badge = document.getElementById('held-count');
if (count > 0) {
badge.textContent = count;
badge.style.display = 'flex';
} else {
badge.style.display = 'none';
}
}
// Checkout
document.getElementById('btn-checkout').addEventListener('click', function() {
const { total } = calculateTotals();
document.getElementById('payment-amount').textContent = '$' + total.toFixed(2);
document.getElementById('cash-received').value = total.toFixed(2);
document.getElementById('change-due').value = '$0.00';
if (POS.customer) {
document.getElementById('wallet-balance').textContent = '$' + parseFloat(POS.customer.wallet_balance || 0).toFixed(2);
}
POS.paymentMethod = 'cash';
document.querySelectorAll('.payment-method-btn').forEach(b => b.classList.remove('active'));
document.querySelector('[data-method="cash"]').classList.add('active');
document.getElementById('cash-fields').style.display = 'block';
document.getElementById('wallet-info').style.display = 'none';
Modal.open('paymentModal');
setTimeout(() => document.getElementById('cash-received').focus(), 100);
});
// Payment method selection
document.querySelectorAll('.payment-method-btn').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('.payment-method-btn').forEach(b => b.classList.remove('active'));
this.classList.add('active');
POS.paymentMethod = this.dataset.method;
document.getElementById('cash-fields').style.display = POS.paymentMethod === 'cash' ? 'block' : 'none';
document.getElementById('wallet-info').style.display = POS.paymentMethod === 'wallet' ? 'block' : 'none';
});
});
// Quick cash buttons
document.querySelectorAll('.quick-cash').forEach(btn => {
btn.addEventListener('click', function() {
document.getElementById('cash-received').value = this.dataset.amount;
calculateChange();
});
});
// Cash change calculation
document.getElementById('cash-received').addEventListener('input', calculateChange);
function calculateChange() {
const received = parseFloat(document.getElementById('cash-received').value) || 0;
const { total } = calculateTotals();
const change = received - total;
document.getElementById('change-due').value = '$' + Math.max(0, change).toFixed(2);
}
// Complete payment
document.getElementById('btn-complete-payment').addEventListener('click', async function() {
const { subtotal, discount, total } = calculateTotals();
if (POS.paymentMethod === 'cash') {
const received = parseFloat(document.getElementById('cash-received').value) || 0;
if (received < total) {
AdminToast.error('Insufficient payment amount');
return;
}
}
if (POS.paymentMethod === 'wallet') {
if (!POS.customer || parseFloat(POS.customer.wallet_balance) < total) {
AdminToast.error('Insufficient wallet balance');
return;
}
}
setLoading(this, true);
try {
const response = await fetch('/api/pos-order.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
items: POS.cart.map(item => ({
product_id: item.id,
name: item.name,
price: item.price,
quantity: item.quantity,
total: item.price * item.quantity
})),
payment_method: POS.paymentMethod,
notes: document.getElementById('order-notes').value,
total: total,
discount: discount,
coupon_code: POS.coupon?.code,
customer_id: POS.customer?.customer_id,
customer_email: POS.customer?.email,
customer_name: POS.customer?.name
})
});
const data = await response.json();
if (data.error) {
AdminToast.error(data.error);
} else {
Modal.close('paymentModal');
showReceipt(data, POS.paymentMethod === 'cash' ? parseFloat(document.getElementById('cash-received').value) : total);
// Reset
POS.cart = [];
POS.coupon = null;
POS.manualDiscount = 0;
removeCustomer();
document.getElementById('coupon-input').value = '';
document.getElementById('manual-discount').value = '';
document.getElementById('applied-discounts').innerHTML = '';
document.getElementById('order-notes').value = '';
renderCart();
AdminToast.success('Order completed! #' + data.order_number);
}
} catch (err) {
AdminToast.error('Failed to process order');
console.error(err);
} finally {
setLoading(this, false);
}
});
// Show receipt
function showReceipt(orderData, amountPaid) {
const { subtotal, discount, tax, total } = calculateTotals();
const change = amountPaid - total;
const receiptHtml = `
<div class="receipt">
<div class="receipt-header">
<h2>Tom's Java Jive</h2>
<p>Order #${orderData.order_number}</p>
<p>${new Date().toLocaleString()}</p>
</div>
<div class="receipt-items">
${orderData.items.map(item => `
<div class="receipt-item">
<span>${item.quantity}x ${item.name}</span>
<span>$${item.total.toFixed(2)}</span>
</div>
`).join('')}
</div>
<div class="receipt-totals">
<div class="receipt-total-row">
<span>Subtotal</span>
<span>$${subtotal.toFixed(2)}</span>
</div>
${discount > 0 ? `
<div class="receipt-total-row">
<span>Discount</span>
<span>-$${discount.toFixed(2)}</span>
</div>
` : ''}
<div class="receipt-total-row">
<span>Tax</span>
<span>$${tax.toFixed(2)}</span>
</div>
<div class="receipt-total-row grand-total">
<span>TOTAL</span>
<span>$${total.toFixed(2)}</span>
</div>
<div class="receipt-total-row">
<span>Paid (${POS.paymentMethod})</span>
<span>$${amountPaid.toFixed(2)}</span>
</div>
${change > 0 ? `
<div class="receipt-total-row">
<span>Change</span>
<span>$${change.toFixed(2)}</span>
</div>
` : ''}
</div>
<div class="receipt-footer">
<p>Thank you for your purchase!</p>
<p>tomsjavajive.com</p>
</div>
</div>
`;
document.getElementById('receiptContent').innerHTML = receiptHtml;
Modal.open('receiptModal');
}
function printReceipt() {
window.print();
}
// Utility
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
}
// Initialize
renderCart();
updateHeldCount();
</script>
<?php require_once __DIR__ . '/includes/footer.php'; ?>