From 49dc107b187f2375fe38b3f134726eab37049291 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Mon, 16 Feb 2026 12:57:34 +0000 Subject: [PATCH] addin point of sale --- assets/css/custom.css | 89 ++++++++++- index.php | 344 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 395 insertions(+), 38 deletions(-) diff --git a/assets/css/custom.css b/assets/css/custom.css index 9313a49..113a9e1 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -65,7 +65,7 @@ body { } .nav-section-title { - font-size: 0.7rem; + font-size: 0.85rem; font-weight: 600; color: #64748b !important; letter-spacing: 0.05em; @@ -303,3 +303,90 @@ body { top: 0; } } + +/* Payment Modal Styles */ +.payment-methods-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + margin-bottom: 20px; +} +.payment-method-btn { + border: 2px solid #e2e8f0; + background: #fff; + padding: 15px 10px; + border-radius: 10px; + text-align: center; + cursor: pointer; + transition: all 0.2s; +} +.payment-method-btn.active { + border-color: #3b82f6; + background: #eff6ff; + color: #3b82f6; +} +.payment-method-btn i { + font-size: 1.5rem; + display: block; + margin-bottom: 5px; +} + +.quick-pay-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 5px; + margin-top: 10px; +} +.quick-pay-btn { + padding: 10px; + border: 1px solid #e2e8f0; + background: #f8fafc; + border-radius: 6px; + text-align: center; + cursor: pointer; + font-weight: 600; +} +.quick-pay-btn:hover { + background: #edf2f7; +} + +.amount-due-box { + background: #f1f5f9; + padding: 15px; + border-radius: 10px; + text-align: center; + margin-bottom: 20px; +} +.amount-due-box .label { + font-size: 0.75rem; + color: #64748b; + text-transform: uppercase; + font-weight: 600; +} +.amount-due-box .value { + font-size: 2.5rem; + font-weight: 800; + color: #0f172a; +} +.payment-line { + background: #f8f9fa; + border-radius: 8px; + padding: 10px; + margin-bottom: 10px; + display: flex; + justify-content: space-between; + align-items: center; + border: 1px solid #e9ecef; +} +.payment-line .method { + font-weight: 600; + text-transform: capitalize; +} +.payment-line .amount { + font-weight: 700; +} +.payment-summary { + border-top: 2px dashed #dee2e6; + margin-top: 15px; + padding-top: 15px; +} diff --git a/index.php b/index.php index 38f2814..a99e672 100644 --- a/index.php +++ b/index.php @@ -101,19 +101,30 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $db->beginTransaction(); $customer_id = !empty($_POST['customer_id']) ? (int)$_POST['customer_id'] : null; - $payment_method = $_POST['payment_method'] ?? 'cash'; + $payments = json_decode($_POST['payments'] ?? '[]', true); $total_amount = (float)$_POST['total_amount']; $discount_code_id = !empty($_POST['discount_code_id']) ? (int)$_POST['discount_code_id'] : null; $discount_amount = (float)($_POST['discount_amount'] ?? 0); $loyalty_redeemed = (float)($_POST['loyalty_redeemed'] ?? 0); - $items = json_decode($_POST['items'], true); + $items = json_decode($_POST['items'] ?? '[]', true); - $net_amount = $total_amount - $discount_amount - $loyalty_redeemed; + if (empty($items)) { + throw new Exception("Cart is empty"); + } + + $net_amount = (float)($total_amount - $discount_amount - $loyalty_redeemed); if ($net_amount < 0) $net_amount = 0; // Loyalty Calculation: 1 point per 1 OMR spent on net amount $loyalty_earned = floor($net_amount); + // Check if credit is used for walk-in + foreach ($payments as $p) { + if ($p['method'] === 'credit' && !$customer_id) { + throw new Exception("Credit payment is only allowed for registered customers"); + } + } + // Create Invoice $stmt = $db->prepare("INSERT INTO invoices (customer_id, invoice_date, status, total_with_vat, paid_amount, type) VALUES (?, CURDATE(), 'paid', ?, ?, 'sale')"); $stmt->execute([$customer_id, $net_amount, $net_amount]); @@ -122,37 +133,52 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { // Add POS Transaction record $stmt = $db->prepare("INSERT INTO pos_transactions (transaction_no, customer_id, total_amount, discount_code_id, discount_amount, loyalty_points_earned, loyalty_points_redeemed, net_amount, payment_method) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"); $transaction_no = 'POS-' . time() . rand(100, 999); - $stmt->execute([$transaction_no, $customer_id, $total_amount, $discount_code_id, $discount_amount, $loyalty_earned, $loyalty_redeemed, $net_amount, $payment_method]); + $methods_str = implode(', ', array_unique(array_map(fn($p) => $p['method'], $payments))); + $stmt->execute([$transaction_no, $customer_id, $total_amount, $discount_code_id, $discount_amount, $loyalty_earned, $loyalty_redeemed, $net_amount, $methods_str]); $pos_id = $db->lastInsertId(); foreach ($items as $item) { + $qty = (float)$item['qty']; + $price = (float)$item['price']; + $subtotal = $qty * $price; + // Add to invoice_items $stmt = $db->prepare("INSERT INTO invoice_items (invoice_id, item_id, quantity, unit_price, total_price) VALUES (?, ?, ?, ?, ?)"); - $stmt->execute([$invoice_id, $item['id'], $item['qty'], $item['price'], $item['qty'] * $item['price']]); + $stmt->execute([$invoice_id, $item['id'], $qty, $price, $subtotal]); // Add to pos_items $stmt = $db->prepare("INSERT INTO pos_items (transaction_id, product_id, quantity, unit_price, subtotal) VALUES (?, ?, ?, ?, ?)"); - $stmt->execute([$pos_id, $item['id'], $item['qty'], $item['price'], $item['qty'] * $item['price']]); + $stmt->execute([$pos_id, $item['id'], $qty, $price, $subtotal]); // Update stock $stmt = $db->prepare("UPDATE stock_items SET stock_quantity = stock_quantity - ? WHERE id = ?"); - $stmt->execute([$item['qty'], $item['id']]); + $stmt->execute([$qty, $item['id']]); } - // Update Customer Loyalty Points + // Update Customer Loyalty Points and Balance if ($customer_id) { - $stmt = $db->prepare("UPDATE customers SET loyalty_points = loyalty_points - ? + ? WHERE id = ?"); - $stmt->execute([$loyalty_redeemed, $loyalty_earned, $customer_id]); + $credit_total = 0; + foreach ($payments as $p) { + if ($p['method'] === 'credit') { + $credit_total += (float)$p['amount']; + } + } + + $stmt = $db->prepare("UPDATE customers SET loyalty_points = loyalty_points - ? + ?, balance = balance - ? WHERE id = ?"); + $stmt->execute([(float)$loyalty_redeemed, (float)$loyalty_earned, (float)$credit_total, $customer_id]); } - // Add Payment - $stmt = $db->prepare("INSERT INTO payments (invoice_id, amount, payment_date, payment_method, notes) VALUES (?, ?, CURDATE(), ?, 'POS Transaction')"); - $stmt->execute([$invoice_id, $net_amount, $payment_method]); + // Add Payments + foreach ($payments as $p) { + $stmt = $db->prepare("INSERT INTO payments (invoice_id, amount, payment_date, payment_method, notes) VALUES (?, ?, CURDATE(), ?, 'POS Transaction')"); + $stmt->execute([$invoice_id, (float)$p['amount'], $p['method']]); + } $db->commit(); echo json_encode(['success' => true, 'invoice_id' => $invoice_id, 'transaction_no' => $transaction_no]); } catch (Exception $e) { - $db->rollBack(); + if (isset($db)) $db->rollBack(); + error_log("POS Error: " . $e->getMessage()); echo json_encode(['success' => false, 'error' => $e->getMessage()]); } exit; @@ -1070,7 +1096,7 @@ $projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Accounting System'; - - - - - -
- - -
@@ -1721,6 +1739,8 @@ $projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Accounting System'; items: [], discount: null, customerPoints: 0, + selectedPaymentMethod: 'cash', + payments: [], add(product) { const existing = this.items.find(item => item.id === product.id); if (existing) { @@ -1922,23 +1942,170 @@ $projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Accounting System'; async checkout() { if (this.items.length === 0) return; - const btn = document.getElementById('checkoutBtn'); + const subtotal = this.items.reduce((sum, item) => sum + (item.price * item.qty), 0); + let discountAmount = 0; + if (this.discount) { + discountAmount = this.discount.type === 'percentage' ? subtotal * (parseFloat(this.discount.value) / 100) : parseFloat(this.discount.value); + } + const redeemSwitch = document.getElementById('redeemLoyalty'); + let loyaltyRedeemed = (redeemSwitch && redeemSwitch.checked) ? Math.min(subtotal - discountAmount, this.customerPoints) : 0; + const total = subtotal - discountAmount - loyaltyRedeemed; + + this.payments = []; + this.renderPayments(); + document.getElementById('paymentAmountDue').innerText = 'OMR ' + total.toFixed(3); + document.getElementById('partialAmount').value = total.toFixed(3); + this.updateRemaining(); + + const modal = new bootstrap.Modal(document.getElementById('posPaymentModal')); + modal.show(); + }, + selectMethod(method, btn) { + this.selectedPaymentMethod = method; + document.querySelectorAll('.payment-method-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + this.updateRemaining(); + }, + fillPartial(amount) { + const input = document.getElementById('partialAmount'); + input.value = parseFloat(amount).toFixed(3); + this.updateRemaining(); + }, + addPaymentLine() { + const amount = parseFloat(document.getElementById('partialAmount').value) || 0; + if (amount <= 0) return; + + this.payments.push({ + method: this.selectedPaymentMethod, + amount: amount + }); + this.renderPayments(); + this.updateRemaining(); + + // Auto-fill remaining for next line if any + const remaining = this.getRemaining(); + document.getElementById('partialAmount').value = remaining > 0 ? remaining.toFixed(3) : '0.000'; + }, + removePaymentLine(index) { + this.payments.splice(index, 1); + this.renderPayments(); + this.updateRemaining(); + }, + getGrandTotal() { + const subtotal = this.items.reduce((sum, item) => sum + (item.price * item.qty), 0); + let discountAmount = 0; + if (this.discount) { + discountAmount = this.discount.type === 'percentage' ? subtotal * (parseFloat(this.discount.value) / 100) : parseFloat(this.discount.value); + } + const redeemSwitch = document.getElementById('redeemLoyalty'); + let loyaltyRedeemed = (redeemSwitch && redeemSwitch.checked) ? Math.min(subtotal - discountAmount, this.customerPoints) : 0; + return subtotal - discountAmount - loyaltyRedeemed; + }, + getRemaining() { + const total = this.getGrandTotal(); + const paid = this.payments.reduce((sum, p) => sum + p.amount, 0); + return total - paid; + }, + renderPayments() { + const container = document.getElementById('paymentList'); + container.innerHTML = this.payments.map((p, i) => ` +
+
+ ${p.method} + OMR ${p.amount.toFixed(3)} +
+ +
+ `).join(''); + }, + updateRemaining() { + const remaining = this.getRemaining(); + const currentInput = parseFloat(document.getElementById('partialAmount').value) || 0; + const display = document.getElementById('paymentRemaining'); + display.innerText = 'OMR ' + Math.max(0, remaining).toFixed(3); + + // Calculate potential change if the user types an amount > remaining + const totalPaid = this.payments.reduce((sum, p) => sum + p.amount, 0); + const grandTotal = this.getGrandTotal(); + const actualChange = Math.max(0, totalPaid - grandTotal); + const potentialChange = Math.max(0, currentInput - remaining); + const displayChange = Math.max(actualChange, potentialChange); + + const changeDisplay = document.getElementById('changeDue'); + if (changeDisplay) { + changeDisplay.innerText = 'OMR ' + displayChange.toFixed(3); + const cashSection = document.getElementById('cashPaymentSection'); + if (displayChange > 0 || this.selectedPaymentMethod === 'cash' || this.payments.some(p => p.method === 'cash')) { + cashSection.style.display = 'block'; + } else { + cashSection.style.display = 'none'; + } + } + + if (remaining <= 0.0001 || currentInput >= remaining - 0.0001) { + display.classList.remove('text-danger'); + display.classList.add('text-success'); + document.getElementById('confirmPaymentBtn').disabled = false; + } else { + display.classList.remove('text-success'); + display.classList.add('text-danger'); + document.getElementById('confirmPaymentBtn').disabled = true; + } + }, + async completeOrder() { + if (this.items.length === 0) { + Swal.fire('Error', 'Cart is empty', 'error'); + return; + } + + // If there's an amount in the input and payments are not enough, add it + const remainingBefore = this.getRemaining(); + const currentInput = parseFloat(document.getElementById('partialAmount').value) || 0; + + if (remainingBefore > 0.0001 && currentInput >= remainingBefore - 0.0001) { + this.payments.push({ + method: this.selectedPaymentMethod, + amount: currentInput + }); + } else if (this.payments.length === 0) { + const total = this.getGrandTotal(); + this.payments.push({ + method: this.selectedPaymentMethod, + amount: total + }); + } + + const remaining = this.getRemaining(); + if (remaining > 0.001) { + Swal.fire('Error', 'Payment is incomplete', 'error'); + return; + } + + const customerId = document.getElementById('posCustomer').value; + if (this.payments.some(p => p.method === 'credit') && !customerId) { + Swal.fire('Error', 'Credit payment is only allowed for registered customers', 'error'); + return; + } + + const btn = document.getElementById('confirmPaymentBtn'); const originalText = btn.innerText; btn.disabled = true; btn.innerText = 'PROCESSING...'; - const subtotal = this.items.reduce((sum, item) => sum + (item.price * item.qty), 0); + const subtotal = this.items.reduce((sum, item) => sum + (parseFloat(item.price) * item.qty), 0); let discountAmount = 0; if (this.discount) { - discountAmount = this.discount.type === 'percentage' ? subtotal * (this.discount.value / 100) : this.discount.value; + discountAmount = this.discount.type === 'percentage' ? subtotal * (parseFloat(this.discount.value) / 100) : parseFloat(this.discount.value); } const redeemSwitch = document.getElementById('redeemLoyalty'); let loyaltyRedeemed = (redeemSwitch && redeemSwitch.checked) ? Math.min(subtotal - discountAmount, this.customerPoints) : 0; const formData = new FormData(); formData.append('action', 'save_pos_transaction'); - formData.append('customer_id', document.getElementById('posCustomer').value); - formData.append('payment_method', document.getElementById('posPaymentMethod').value); + formData.append('customer_id', customerId); + formData.append('payments', JSON.stringify(this.payments)); formData.append('total_amount', subtotal); formData.append('discount_code_id', this.discount ? this.discount.id : ''); formData.append('discount_amount', discountAmount); @@ -1947,8 +2114,18 @@ $projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Accounting System'; try { const resp = await fetch('index.php', { method: 'POST', body: formData }); - const result = await resp.json(); + const text = await resp.text(); + let result; + try { + result = JSON.parse(text); + } catch (e) { + console.error('Invalid JSON response:', text); + throw new Error('Server returned an invalid response'); + } + if (result.success) { + const payModal = bootstrap.Modal.getInstance(document.getElementById('posPaymentModal')); + if (payModal) payModal.hide(); this.showReceipt(result.invoice_id, discountAmount, loyaltyRedeemed, result.transaction_no); } else { Swal.fire('Error', result.error, 'error'); @@ -1957,7 +2134,7 @@ $projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Accounting System'; } } catch (err) { console.error(err); - Swal.fire('Error', 'Something went wrong', 'error'); + Swal.fire('Error', err.message || 'Something went wrong', 'error'); btn.disabled = false; btn.innerText = originalText; } @@ -1965,7 +2142,12 @@ $projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Accounting System'; showReceipt(invId, discountAmount, loyaltyRedeemed, transactionNo) { const container = document.getElementById('posReceiptContent'); const customerName = document.getElementById('posCustomer').options[document.getElementById('posCustomer').selectedIndex].text; - const paymentMethod = document.getElementById('posPaymentMethod').value.toUpperCase(); + const paymentsHtml = this.payments.map(p => ` +
+ ${p.method} + OMR ${p.amount.toFixed(3)} +
+ `).join(''); const date = new Date().toLocaleString(); let itemsHtml = this.items.map(item => ` @@ -1994,8 +2176,11 @@ $projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Accounting System';
- Customer: ${customerName}
- Payment: ${paymentMethod} + Customer: ${customerName} +
+
+ Payments: + ${paymentsHtml}
@@ -3193,6 +3378,91 @@ document.addEventListener('DOMContentLoaded', function() { + + +