38682-vm/assets/js/main.js
2026-02-22 18:21:25 +00:00

775 lines
32 KiB
JavaScript

document.addEventListener('DOMContentLoaded', () => {
let cart = [];
const cartItemsContainer = document.getElementById('cart-items');
const cartTotalPrice = document.getElementById('cart-total-price');
const cartSubtotal = document.getElementById('cart-subtotal');
const cartDiscountInput = document.getElementById('cart-discount-input');
const checkoutBtn = document.getElementById('checkout-btn');
// Loyalty State
let isLoyaltyRedemption = false;
const loyaltySection = document.getElementById('loyalty-section');
const loyaltyPointsDisplay = document.getElementById('loyalty-points-display');
const loyaltyMessage = document.getElementById('loyalty-message');
const redeemLoyaltyBtn = document.getElementById('redeem-loyalty-btn');
// Table Management
let currentTableId = null;
let currentTableName = null;
const tableDisplay = document.getElementById('current-table-display');
const tableModalEl = document.getElementById('tableSelectionModal');
const tableSelectionModal = new bootstrap.Modal(tableModalEl);
// Variant Management
const variantModalEl = document.getElementById('variantSelectionModal');
const variantSelectionModal = new bootstrap.Modal(variantModalEl);
let pendingProduct = null;
// Customer Search Elements
const customerSearchInput = document.getElementById('customer-search');
const customerResults = document.getElementById('customer-results');
const selectedCustomerId = document.getElementById('selected-customer-id');
const clearCustomerBtn = document.getElementById('clear-customer');
const customerInfo = document.getElementById('customer-info');
const customerNameDisplay = document.getElementById('customer-name-display');
let currentCustomer = null;
// Payment Modal
const paymentModalEl = document.getElementById('paymentSelectionModal');
const paymentSelectionModal = new bootstrap.Modal(paymentModalEl);
const paymentMethodsContainer = document.getElementById('payment-methods-container');
// Product Search & Filter
const productSearchInput = document.getElementById('product-search-input');
let currentCategory = 'all';
let currentSearchQuery = '';
// Helper for currency
function formatCurrency(amount) {
const settings = (typeof COMPANY_SETTINGS !== 'undefined') ? COMPANY_SETTINGS : { currency_symbol: '$', currency_decimals: 2 };
const symbol = settings.currency_symbol || '$';
const decimals = parseInt(settings.currency_decimals || 2);
return symbol + parseFloat(amount).toFixed(decimals);
}
// --- Product Filtering (Category + Search) ---
function filterProducts() {
const items = document.querySelectorAll('.product-item');
items.forEach(item => {
const matchesCategory = (currentCategory == 'all' || item.dataset.categoryId == currentCategory);
const name = item.querySelector('.card-title').textContent.toLowerCase();
const matchesSearch = name.includes(currentSearchQuery);
if (matchesCategory && matchesSearch) {
item.style.display = 'block';
} else {
item.style.display = 'none';
}
});
}
window.filterCategory = function(categoryId, btnElement) {
currentCategory = categoryId;
// Update Active Button State
if (btnElement) {
document.querySelectorAll('.category-btn').forEach(btn => btn.classList.remove('active'));
btnElement.classList.add('active');
} else if (typeof btnElement === 'undefined' && categoryId !== 'all') {
// Try to find the button corresponding to this category ID
}
filterProducts();
};
if (productSearchInput) {
productSearchInput.addEventListener('input', (e) => {
currentSearchQuery = e.target.value.trim().toLowerCase();
filterProducts();
});
}
// --- Customer Search ---
let searchTimeout;
if (customerSearchInput) {
customerSearchInput.addEventListener('input', (e) => {
const query = e.target.value.trim();
clearTimeout(searchTimeout);
if (query.length < 2) {
customerResults.style.display = 'none';
return;
}
searchTimeout = setTimeout(() => {
fetch(`api/search_customers.php?q=${encodeURIComponent(query)}`)
.then(res => res.json())
.then(data => {
customerResults.innerHTML = '';
if (data.length > 0) {
data.forEach(cust => {
const a = document.createElement('a');
a.href = '#';
a.className = 'list-group-item list-group-item-action';
a.innerHTML = `<div class="fw-bold">${cust.name}</div><div class="small text-muted">${cust.phone || ''}</div>`;
a.onclick = (ev) => {
ev.preventDefault();
selectCustomer(cust);
};
customerResults.appendChild(a);
});
customerResults.style.display = 'block';
} else {
customerResults.innerHTML = '<div class="list-group-item text-muted">No results found</div>';
customerResults.style.display = 'block';
}
});
}, 300);
});
// Close search on click outside
document.addEventListener('click', (e) => {
if (!customerSearchInput.contains(e.target) && !customerResults.contains(e.target)) {
customerResults.style.display = 'none';
}
});
}
function selectCustomer(cust) {
currentCustomer = cust;
selectedCustomerId.value = cust.id;
customerNameDisplay.textContent = cust.name;
customerSearchInput.value = cust.name; // Show name in input
customerSearchInput.disabled = true; // Lock input
customerResults.style.display = 'none';
clearCustomerBtn.classList.remove('d-none');
// customerInfo.classList.remove('d-none');
// Loyalty Logic
if (loyaltySection) {
loyaltySection.classList.remove('d-none');
loyaltyPointsDisplay.textContent = cust.points + ' pts';
if (cust.eligible_for_free_meal) {
redeemLoyaltyBtn.disabled = false;
loyaltyMessage.innerHTML = '<span class="text-success fw-bold">Eligible for Free Meal!</span>';
} else {
redeemLoyaltyBtn.disabled = true;
const needed = cust.points_needed || 0;
loyaltyMessage.textContent = `${needed} pts away from a free meal.`;
}
}
// Reset redemption state when switching customer (though usually we clear first)
isLoyaltyRedemption = false;
}
if (clearCustomerBtn) {
clearCustomerBtn.addEventListener('click', () => {
currentCustomer = null;
selectedCustomerId.value = '';
customerSearchInput.value = '';
customerSearchInput.disabled = false;
clearCustomerBtn.classList.add('d-none');
customerInfo.classList.add('d-none');
// Hide Loyalty
if (loyaltySection) loyaltySection.classList.add('d-none');
isLoyaltyRedemption = false;
// If we had a discount applied via loyalty, remove it?
// Better to just let the user manually adjust if they want, but technically if it was 100% off due to loyalty, we should revert.
// But tracking "discount source" is complex. For now, let's just leave discount as is or reset it if it equals total?
// Safer to reset discount to 0.
cartDiscountInput.value = 0;
updateCart();
customerSearchInput.focus();
});
}
// Loyalty Redeem Click
if (redeemLoyaltyBtn) {
redeemLoyaltyBtn.addEventListener('click', () => {
if (cart.length === 0) {
showToast("Cart is empty!", "warning");
return;
}
if (!currentCustomer || !currentCustomer.eligible_for_free_meal) return;
if (confirm("Redeem 70 points for a free meal? This will apply a full discount to the current order.")) {
isLoyaltyRedemption = true;
// Calculate total and apply as discount
const subtotal = cart.reduce((acc, item) => acc + (item.price * item.quantity), 0);
cartDiscountInput.value = subtotal.toFixed(2);
updateCart();
showToast("Loyalty Redemption Applied!", "success");
redeemLoyaltyBtn.disabled = true; // Prevent double click
loyaltyMessage.innerHTML = '<span class="text-success fw-bold"><i class="bi bi-check-circle"></i> Redeemed! Place order to finalize.</span>';
}
});
}
// --- Table & Order Type Logic ---
const orderTypeInputs = document.querySelectorAll('input[name="order_type"]');
function checkOrderType() {
const checked = document.querySelector('input[name="order_type"]:checked');
if (!checked) return;
const selected = checked.value;
if (selected === 'dine-in') {
if (!currentTableId) openTableSelectionModal();
if (tableDisplay) tableDisplay.style.display = 'inline-block';
} else {
if (tableDisplay) tableDisplay.style.display = 'none';
}
}
orderTypeInputs.forEach(input => {
input.addEventListener('change', checkOrderType);
});
function openTableSelectionModal() {
const container = document.getElementById('table-list-container');
container.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary" role="status"></div></div>';
tableSelectionModal.show();
const outletId = new URLSearchParams(window.location.search).get('outlet_id') || 1;
fetch(`api/tables.php?outlet_id=${outletId}`)
.then(res => res.json())
.then(data => {
if (data.success) {
renderTables(data.tables);
} else {
container.innerHTML = `<div class="alert alert-danger">${data.error}</div>`;
}
})
.catch(() => {
container.innerHTML = `<div class="alert alert-danger">Error loading tables.</div>`;
});
}
function renderTables(tables) {
const container = document.getElementById('table-list-container');
container.innerHTML = '';
if (tables.length === 0) {
container.innerHTML = '<div class="col-12 text-center">No tables found.</div>';
return;
}
tables.forEach(table => {
const isOccupied = table.is_occupied;
const col = document.createElement('div');
col.className = 'col-6 col-md-4 col-lg-3';
col.innerHTML = `
<div class="card h-100 shadow-sm ${isOccupied ? 'bg-secondary text-white opacity-50' : 'border-primary text-primary'}"
style="cursor: ${isOccupied ? 'not-allowed' : 'pointer'}"
${!isOccupied ? `onclick="selectTable(${table.id}, '${table.name}')"` : ''}>
<div class="card-body text-center p-3">
<h5 class="fw-bold mb-1">${table.name}</h5>
<small>${table.capacity} Pax</small>
<div class="mt-2 badge ${isOccupied ? 'bg-dark' : 'bg-success'}">${isOccupied ? 'Occupied' : 'Available'}</div>
</div>
</div>
`;
container.appendChild(col);
});
}
window.selectTable = function(id, name) {
currentTableId = id;
currentTableName = name;
if (tableDisplay) {
tableDisplay.innerHTML = `Table: ${name}`;
tableDisplay.style.display = 'block';
}
tableSelectionModal.hide();
showToast(`Selected Table: ${name}`, 'success');
};
// --- Cart Logic ---
document.querySelectorAll('.add-to-cart').forEach(card => {
card.addEventListener('click', (e) => {
const target = e.currentTarget;
const product = {
id: target.dataset.id,
name: target.dataset.name,
price: parseFloat(target.dataset.price),
base_price: parseFloat(target.dataset.price),
hasVariants: target.dataset.hasVariants === 'true',
quantity: 1,
variant_id: null,
variant_name: null
};
if (product.hasVariants) {
openVariantModal(product);
} else {
addToCart(product);
}
});
});
function openVariantModal(product) {
pendingProduct = product;
const variants = PRODUCT_VARIANTS[product.id] || [];
const list = document.getElementById('variant-list');
const title = document.getElementById('variantModalTitle');
title.textContent = `Select option for ${product.name}`;
list.innerHTML = '';
// Add "Base" option if needed, but usually variants are mandatory if they exist.
// Assuming mandatory for now or include a base option.
variants.forEach(v => {
const btn = document.createElement('button');
btn.className = 'list-group-item list-group-item-action d-flex justify-content-between align-items-center';
const adj = parseFloat(v.price_adjustment);
const sign = adj > 0 ? '+' : '';
const priceText = adj !== 0 ? `${sign}${formatCurrency(adj)}` : '';
const finalPrice = product.base_price + adj;
btn.innerHTML = `
<span>${v.name}</span>
<span class="fw-bold">${formatCurrency(finalPrice)}</span>
`;
btn.onclick = () => {
pendingProduct.variant_id = v.id;
pendingProduct.variant_name = v.name;
pendingProduct.price = finalPrice;
addToCart(pendingProduct);
variantSelectionModal.hide();
};
list.appendChild(btn);
});
variantSelectionModal.show();
}
function addToCart(product) {
// Unique ID for cart item is ProductID + VariantID
const existing = cart.find(item => item.id === product.id && item.variant_id === product.variant_id);
if (existing) {
existing.quantity++;
} else {
// Clone to avoid reference issues
cart.push({...product});
}
updateCart();
// If loyalty was applied, maybe re-apply or warn?
// Simple: If loyalty applied, update discount to match new total?
if (isLoyaltyRedemption) {
// Re-calculate full discount
// Wait, updateCart is called below.
// We need to re-calc discount after update.
// But updateCart uses discount input value.
// Let's just reset loyalty on cart change? Or re-apply 100% discount.
// Better: Reset loyalty on cart modification to avoid confusion?
// Or keep it simple: Just update discount.
}
}
window.changeQuantity = function(index, delta) {
if (cart[index]) {
cart[index].quantity += delta;
if (cart[index].quantity <= 0) {
removeFromCart(index);
} else {
updateCart();
}
}
};
function updateCart() {
if (cart.length === 0) {
cartItemsContainer.innerHTML = `
<div class="text-center text-muted mt-5">
<i class="bi bi-basket3 fs-1 text-light"></i>
<p class="mt-2">Cart is empty</p>
</div>`;
cartSubtotal.innerText = formatCurrency(0);
cartTotalPrice.innerText = formatCurrency(0);
checkoutBtn.disabled = true;
return;
}
cartItemsContainer.innerHTML = '';
let subtotal = 0;
cart.forEach((item, index) => {
const itemTotal = item.price * item.quantity;
subtotal += itemTotal;
const row = document.createElement('div');
row.className = 'd-flex justify-content-between align-items-center mb-3 border-bottom pb-2';
const variantLabel = item.variant_name ? `<span class="badge bg-light text-dark border ms-1">${item.variant_name}</span>` : '';
row.innerHTML = `
<div class="flex-grow-1 me-2">
<div class="fw-bold text-truncate" style="max-width: 140px;">${item.name}</div>
<div class="small text-muted">${formatCurrency(item.price)} ${variantLabel}</div>
</div>
<div class="d-flex align-items-center bg-light rounded px-1">
<button class="btn btn-sm text-secondary p-0" style="width: 24px;" onclick="changeQuantity(${index}, -1)"><i class="bi bi-dash"></i></button>
<span class="mx-1 fw-bold small" style="min-width: 20px; text-align: center;">${item.quantity}</span>
<button class="btn btn-sm text-secondary p-0" style="width: 24px;" onclick="changeQuantity(${index}, 1)"><i class="bi bi-plus"></i></button>
</div>
<div class="text-end ms-3" style="min-width: 60px;">
<div class="fw-bold">${formatCurrency(itemTotal)}</div>
<button class="btn btn-sm text-danger p-0 mt-1" style="font-size: 0.8rem;" onclick="removeFromCart(${index})">Remove</button>
</div>
`;
cartItemsContainer.appendChild(row);
});
cartSubtotal.innerText = formatCurrency(subtotal);
// Discount
let discount = parseFloat(cartDiscountInput.value) || 0;
// Auto-update discount if loyalty active
if (isLoyaltyRedemption) {
discount = subtotal;
cartDiscountInput.value = subtotal.toFixed(2);
}
let total = subtotal - discount;
if (total < 0) total = 0;
cartTotalPrice.innerText = formatCurrency(total);
checkoutBtn.disabled = false;
}
if (cartDiscountInput) {
cartDiscountInput.addEventListener('input', () => {
// If user manually changes discount, maybe disable loyalty flag?
// But if they just tweak it, it's fine.
// Ideally, if they lower it, loyalty flag might still be true but that's weird.
// Let's assume manual input overrides auto-loyalty.
updateCart();
});
}
window.removeFromCart = function(index) {
cart.splice(index, 1);
updateCart();
};
// --- Payment Selection Logic ---
function renderPaymentMethods() {
if (!paymentMethodsContainer) return;
paymentMethodsContainer.innerHTML = '';
if (typeof PAYMENT_TYPES !== 'undefined' && PAYMENT_TYPES.length > 0) {
PAYMENT_TYPES.forEach(pt => {
const col = document.createElement('div');
col.className = 'col-6';
col.innerHTML = `
<button class="btn btn-outline-primary w-100 payment-btn d-flex flex-column align-items-center justify-content-center"
onclick="processOrder(${pt.id}, '${pt.name}')">
<i class="bi ${getPaymentIcon(pt.name)} fs-3 mb-1"></i>
<span>${pt.name}</span>
</button>
`;
paymentMethodsContainer.appendChild(col);
});
} else {
paymentMethodsContainer.innerHTML = '<div class="alert alert-warning">No payment methods configured.</div>';
}
}
function getPaymentIcon(name) {
const n = name.toLowerCase();
if (n.includes('cash')) return 'bi-cash-coin';
if (n.includes('card') || n.includes('visa') || n.includes('master')) return 'bi-credit-card';
if (n.includes('qr') || n.includes('scan')) return 'bi-qr-code';
return 'bi-wallet2';
}
// Initialize Payment Methods
renderPaymentMethods();
// --- Checkout Flow ---
checkoutBtn.addEventListener('click', () => {
if (cart.length === 0) return;
const orderTypeInput = document.querySelector('input[name="order_type"]:checked');
const orderType = orderTypeInput ? orderTypeInput.value : 'dine-in';
if (orderType === 'dine-in' && !currentTableId) {
showToast('Please select a table first', 'warning');
openTableSelectionModal();
return;
}
// Open Payment Modal instead of direct submission
paymentSelectionModal.show();
});
window.processOrder = function(paymentTypeId, paymentTypeName) {
const orderTypeInput = document.querySelector('input[name="order_type"]:checked');
const orderType = orderTypeInput ? orderTypeInput.value : 'dine-in';
const subtotal = cart.reduce((acc, item) => acc + (item.price * item.quantity), 0);
const discount = parseFloat(cartDiscountInput.value) || 0;
const totalAmount = Math.max(0, subtotal - discount);
const custId = selectedCustomerId.value;
const orderData = {
table_number: (orderType === 'dine-in') ? currentTableId : null,
order_type: orderType,
customer_id: custId || null,
outlet_id: new URLSearchParams(window.location.search).get('outlet_id') || 1,
payment_type_id: paymentTypeId,
total_amount: totalAmount,
discount: discount,
redeem_loyalty: isLoyaltyRedemption, // Send flag
items: cart.map(item => ({
product_id: item.id,
quantity: item.quantity,
unit_price: item.price,
variant_id: item.variant_id
}))
};
// Disable all payment buttons
const btns = paymentMethodsContainer.querySelectorAll('button');
btns.forEach(b => b.disabled = true);
fetch('api/order.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(orderData)
})
.then(res => res.json())
.then(data => {
btns.forEach(b => b.disabled = false);
paymentSelectionModal.hide();
if (data.success) {
// Print Receipt
printReceipt({
orderId: data.order_id,
customer: currentCustomer,
items: [...cart],
total: totalAmount,
discount: discount,
orderType: orderType,
tableNumber: (orderType === 'dine-in') ? currentTableName : null,
date: new Date().toLocaleString(),
paymentMethod: paymentTypeName,
loyaltyRedeemed: isLoyaltyRedemption
});
cart = [];
cartDiscountInput.value = 0;
isLoyaltyRedemption = false; // Reset
updateCart();
if (clearCustomerBtn) clearCustomerBtn.click();
showToast(`Order #${data.order_id} placed!`, 'success');
} else {
showToast(`Error: ${data.error}`, 'danger');
}
})
.catch(err => {
btns.forEach(b => b.disabled = false);
paymentSelectionModal.hide();
showToast('Network Error', 'danger');
});
};
function showToast(msg, type = 'primary') {
const toastContainer = document.getElementById('toast-container');
const id = 'toast-' + Date.now();
const html = `
<div id="${id}" class="toast align-items-center text-white bg-${type} border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">${msg}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
`;
toastContainer.insertAdjacentHTML('beforeend', html);
const el = document.getElementById(id);
const t = new bootstrap.Toast(el, { delay: 3000 });
t.show();
el.addEventListener('hidden.bs.toast', () => el.remove());
}
function printReceipt(data) {
const width = 300;
const height = 600;
const left = (screen.width - width) / 2;
const top = (screen.height - height) / 2;
const win = window.open('', 'Receipt', `width=${width},height=${height},top=${top},left=${left}`);
const itemsHtml = data.items.map(item => `
<tr>
<td style="padding: 2px 0;">
${item.name} <br>
${item.variant_name ? `<small>(${item.variant_name})</small>` : ''}
</td>
<td style="text-align: right; vertical-align: top;">${item.quantity} x ${formatCurrency(item.price)}</td>
</tr>
`).join('');
const customerHtml = data.customer ? `
<div style="margin-bottom: 10px; border-bottom: 1px dashed #000; padding-bottom: 5px;">
<strong>Customer:</strong><br>
${data.customer.name}<br>
${data.customer.phone || ''}
</div>
` : '';
const tableHtml = data.tableNumber ? `<div>Table: ${data.tableNumber}</div>` : '';
const paymentHtml = data.paymentMethod ? `<div>Payment: ${data.paymentMethod}</div>` : '';
const loyaltyHtml = data.loyaltyRedeemed ? `<div><strong>* Free Meal Redeemed *</strong></div>` : '';
const html = `
<html>
<head>
<title>Receipt #${data.orderId}</title>
<style>
body { font-family: 'Courier New', monospace; font-size: 12px; margin: 0; padding: 10px; }
.header { text-align: center; margin-bottom: 10px; }
.header h2 { margin: 0; font-size: 16px; }
table { width: 100%; border-collapse: collapse; }
.totals { margin-top: 10px; border-top: 1px dashed #000; padding-top: 5px; }
.footer { text-align: center; margin-top: 20px; font-size: 10px; }
@media print {
.no-print { display: none; }
}
</style>
</head>
<body>
<div class="header">
<h2>FLATLOGIC POS</h2>
<div>123 Main St, City</div>
<div>Tel: 123-456-7890</div>
</div>
<div style="margin-bottom: 10px;">
<div>Order #${data.orderId}</div>
<div>${data.date}</div>
<div>Type: ${data.orderType.toUpperCase()}</div>
${tableHtml}
${paymentHtml}
${loyaltyHtml}
</div>
${customerHtml}
<table>
${itemsHtml}
</table>
<div class="totals">
<table style="width: 100%">
<tr>
<td>Subtotal</td>
<td style="text-align: right">${formatCurrency(data.total + data.discount)}</td>
</tr>
${data.discount > 0 ? `
<tr>
<td>Discount</td>
<td style="text-align: right">-${formatCurrency(data.discount)}</td>
</tr>` : ''}
<tr style="font-weight: bold; font-size: 14px;">
<td>Total</td>
<td style="text-align: right">${formatCurrency(data.total)}</td>
</tr>
</table>
</div>
<div class="footer">
Thank you for your visit!<br>
Please come again.
</div>
<script>
window.onload = function() {
window.print();
setTimeout(function() { window.close(); }, 500);
}
</script>
</body>
</html>
`;
win.document.write(html);
win.document.close();
}
// Initialize logic
checkOrderType();
// --- Add Customer Logic ---
const addCustomerBtn = document.getElementById('add-customer-btn');
const addCustomerModalEl = document.getElementById('addCustomerModal');
if (addCustomerBtn && addCustomerModalEl) {
const addCustomerModal = new bootstrap.Modal(addCustomerModalEl);
const saveCustomerBtn = document.getElementById('save-new-customer');
const newCustomerName = document.getElementById('new-customer-name');
const newCustomerPhone = document.getElementById('new-customer-phone');
const phoneError = document.getElementById('phone-error');
addCustomerBtn.addEventListener('click', () => {
newCustomerName.value = '';
newCustomerPhone.value = '';
phoneError.classList.add('d-none');
addCustomerModal.show();
});
saveCustomerBtn.addEventListener('click', () => {
const name = newCustomerName.value.trim();
const phone = newCustomerPhone.value.trim();
if (name === '') {
showToast('Name is required', 'warning');
return;
}
// 8 digits validation
if (!/^\d{8}$/.test(phone)) {
phoneError.classList.remove('d-none');
return;
} else {
phoneError.classList.add('d-none');
}
saveCustomerBtn.disabled = true;
saveCustomerBtn.textContent = 'Saving...';
fetch('api/create_customer.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name, phone: phone })
})
.then(res => res.json())
.then(data => {
saveCustomerBtn.disabled = false;
saveCustomerBtn.textContent = 'Save Customer';
if (data.success) {
addCustomerModal.hide();
selectCustomer(data.customer);
showToast('Customer created successfully', 'success');
} else {
showToast(data.error || 'Error creating customer', 'danger');
}
})
.catch(err => {
saveCustomerBtn.disabled = false;
saveCustomerBtn.textContent = 'Save Customer';
showToast('Network error', 'danger');
});
});
}
});