From c826515451e2bef0ee5bb036278cd151592c0616 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Mon, 16 Feb 2026 12:22:13 +0000 Subject: [PATCH] Autosave: 20260216-122213 --- assets/css/custom.css | 165 ++ .../20260216_pos_advanced_features.sql | 44 + db/migrations/20260216_setup_pos_full.sql | 57 + index.php | 1865 +++++++++-------- 4 files changed, 1278 insertions(+), 853 deletions(-) create mode 100644 db/migrations/20260216_pos_advanced_features.sql create mode 100644 db/migrations/20260216_setup_pos_full.sql diff --git a/assets/css/custom.css b/assets/css/custom.css index 0e5af3d..9313a49 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -69,6 +69,116 @@ body { font-weight: 600; color: #64748b !important; letter-spacing: 0.05em; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; +} + +.nav-section-title:hover { + color: #94a3b8 !important; +} + +.nav-section-title i.bi-chevron-down { + transition: transform 0.2s; + font-size: 0.6rem; +} + +.nav-section-title.collapsed i.bi-chevron-down { + transform: rotate(-90deg); +} + +/* POS Styles */ +.pos-container { + display: flex; + height: calc(100vh - 120px); + gap: 20px; +} +.pos-products { + flex: 1; + overflow-y: auto; + padding-right: 10px; +} +.pos-cart { + width: 400px; + background: #fff; + border-radius: 12px; + box-shadow: 0 4px 15px rgba(0,0,0,0.05); + display: flex; + flex-direction: column; + height: 100%; +} +.product-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 15px; +} +.product-card { + background: #fff; + border-radius: 10px; + border: 1px solid #edf2f7; + padding: 15px; + text-align: center; + cursor: pointer; + transition: all 0.2s; + display: flex; + flex-direction: column; + justify-content: space-between; +} +.product-card:hover { + transform: translateY(-5px); + box-shadow: 0 10px 20px rgba(0,0,0,0.05); + border-color: #3b82f6; +} +.product-card img { + width: 100%; + height: 120px; + object-fit: contain; + margin-bottom: 10px; + border-radius: 8px; +} +.product-card .price { + font-weight: 700; + color: #2d3748; +} +.cart-items { + flex: 1; + overflow-y: auto; + padding: 20px; +} +.cart-item { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; + padding-bottom: 15px; + border-bottom: 1px solid #edf2f7; +} +.cart-total { + padding: 20px; + background: #f8fafc; + border-bottom-left-radius: 12px; + border-bottom-right-radius: 12px; +} +.qty-controls { + display: flex; + align-items: center; + gap: 10px; +} +.qty-btn { + width: 24px; + height: 24px; + border-radius: 50%; + border: 1px solid #e2e8f0; + background: #fff; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 14px; +} +.qty-btn:hover { + background: #edf2f7; } [dir="rtl"] .nav-link i { @@ -138,3 +248,58 @@ body { [dir="rtl"] .text-start { text-align: right !important; } + +/* Thermal Receipt Styles */ +.thermal-receipt { + width: 80mm; + margin: 0 auto; + padding: 10px; + font-family: 'Courier New', Courier, monospace; + font-size: 12px; + line-height: 1.2; + background: #fff; + color: #000; +} +.thermal-receipt .center { + text-align: center; +} +.thermal-receipt .separator { + border-top: 1px dashed #000; + margin: 10px 0; +} +.thermal-receipt table { + width: 100%; +} +.thermal-receipt table th { + text-align: left; + border-bottom: 1px dashed #000; + font-size: 10px; +} +.thermal-receipt table td { + padding: 5px 0; + font-size: 10px; +} +.thermal-receipt .total-row { + font-weight: bold; + font-size: 14px; +} + +@media print { + .thermal-receipt-print { + width: 80mm !important; + margin: 0 !important; + padding: 5mm !important; + } + body.printing-receipt * { + visibility: hidden; + } + body.printing-receipt .thermal-receipt-print, + body.printing-receipt .thermal-receipt-print * { + visibility: visible; + } + body.printing-receipt .thermal-receipt-print { + position: absolute; + left: 0; + top: 0; + } +} diff --git a/db/migrations/20260216_pos_advanced_features.sql b/db/migrations/20260216_pos_advanced_features.sql new file mode 100644 index 0000000..31d9626 --- /dev/null +++ b/db/migrations/20260216_pos_advanced_features.sql @@ -0,0 +1,44 @@ +-- Held Carts Table +CREATE TABLE IF NOT EXISTS pos_held_carts ( + id INT AUTO_INCREMENT PRIMARY KEY, + cart_name VARCHAR(100) NOT NULL, + items_json TEXT NOT NULL, + customer_id INT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE SET NULL +); + +-- Discount Codes Table +CREATE TABLE IF NOT EXISTS discount_codes ( + id INT AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(50) UNIQUE NOT NULL, + type ENUM('percentage', 'fixed') NOT NULL DEFAULT 'percentage', + value DECIMAL(15, 3) NOT NULL, + min_purchase DECIMAL(15, 3) DEFAULT 0.000, + expiry_date DATE NULL, + status ENUM('active', 'inactive') DEFAULT 'active', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Update Customers for Loyalty Points +-- Check if column exists first (handled by IF NOT EXISTS in some dialects, but let's be safe for MySQL 5.7/MariaDB) +-- Using a procedure or just trying it. MySQL 8.0/MariaDB supports IF NOT EXISTS for ADD COLUMN but older versions don't always. +-- For this VM, we can just run it. + +ALTER TABLE customers ADD COLUMN IF NOT EXISTS loyalty_points DECIMAL(15, 3) DEFAULT 0.000; + +-- Update POS Transactions for Discounts and Loyalty +ALTER TABLE pos_transactions + ADD COLUMN IF NOT EXISTS discount_code_id INT NULL, + ADD COLUMN IF NOT EXISTS discount_amount DECIMAL(15, 3) DEFAULT 0.000, + ADD COLUMN IF NOT EXISTS loyalty_points_earned DECIMAL(15, 3) DEFAULT 0.000, + ADD COLUMN IF NOT EXISTS loyalty_points_redeemed DECIMAL(15, 3) DEFAULT 0.000, + ADD COLUMN IF NOT EXISTS net_amount DECIMAL(15, 3) NOT NULL DEFAULT 0.000; + +-- Adding foreign key separately to be safe +-- ALTER TABLE pos_transactions ADD FOREIGN KEY (discount_code_id) REFERENCES discount_codes(id) ON DELETE SET NULL; + +-- Insert some dummy discount codes +INSERT IGNORE INTO discount_codes (code, type, value, min_purchase) VALUES +('WELCOME10', 'percentage', 10.000, 0.000), +('SAVE5', 'fixed', 5.000, 50.000); diff --git a/db/migrations/20260216_setup_pos_full.sql b/db/migrations/20260216_setup_pos_full.sql new file mode 100644 index 0000000..35b2450 --- /dev/null +++ b/db/migrations/20260216_setup_pos_full.sql @@ -0,0 +1,57 @@ +-- Main POS Transactions Table +CREATE TABLE IF NOT EXISTS pos_transactions ( + id INT AUTO_INCREMENT PRIMARY KEY, + transaction_no VARCHAR(50) UNIQUE NOT NULL, + customer_id INT NULL, + total_amount DECIMAL(15, 3) NOT NULL, + tax_amount DECIMAL(15, 3) DEFAULT 0.000, + discount_code_id INT NULL, + discount_amount DECIMAL(15, 3) DEFAULT 0.000, + loyalty_points_earned DECIMAL(15, 3) DEFAULT 0.000, + loyalty_points_redeemed DECIMAL(15, 3) DEFAULT 0.000, + net_amount DECIMAL(15, 3) NOT NULL DEFAULT 0.000, + payment_method ENUM('cash', 'card', 'transfer') NOT NULL, + status ENUM('completed', 'refunded', 'cancelled') DEFAULT 'completed', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by INT NULL +); + +-- POS Items Table +CREATE TABLE IF NOT EXISTS pos_items ( + id INT AUTO_INCREMENT PRIMARY KEY, + transaction_id INT NOT NULL, + product_id INT NOT NULL, + quantity DECIMAL(15, 3) NOT NULL, + unit_price DECIMAL(15, 3) NOT NULL, + subtotal DECIMAL(15, 3) NOT NULL, + FOREIGN KEY (transaction_id) REFERENCES pos_transactions(id) ON DELETE CASCADE +); + +-- Held Carts Table +CREATE TABLE IF NOT EXISTS pos_held_carts ( + id INT AUTO_INCREMENT PRIMARY KEY, + cart_name VARCHAR(100) NOT NULL, + items_json TEXT NOT NULL, + customer_id INT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Discount Codes Table +CREATE TABLE IF NOT EXISTS discount_codes ( + id INT AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(50) UNIQUE NOT NULL, + type ENUM('percentage', 'fixed') NOT NULL DEFAULT 'percentage', + value DECIMAL(15, 3) NOT NULL, + min_purchase DECIMAL(15, 3) DEFAULT 0.000, + expiry_date DATE NULL, + status ENUM('active', 'inactive') DEFAULT 'active', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Add Loyalty Points to Customers if missing +ALTER TABLE customers ADD COLUMN IF NOT EXISTS loyalty_points DECIMAL(15, 3) DEFAULT 0.000; + +-- Insert initial discount codes +INSERT IGNORE INTO discount_codes (code, type, value, min_purchase) VALUES +('WELCOME10', 'percentage', 10.000, 0.000), +('SAVE5', 'fixed', 5.000, 50.000); diff --git a/index.php b/index.php index 2e961b0..38f2814 100644 --- a/index.php +++ b/index.php @@ -44,7 +44,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['action'])) { if ($_GET['action'] === 'get_payments') { header('Content-Type: application/json'); $invoice_id = (int)$_GET['invoice_id']; - $stmt = db()->prepare("SELECT p.*, i.id as inv_id, c.name as customer_name + $stmt = db()->prepare("SELECT p.*, i.id as inv_id, i.type as inv_type, c.name as customer_name FROM payments p JOIN invoices i ON p.invoice_id = i.id LEFT JOIN customers c ON i.customer_id = c.id @@ -60,7 +60,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['action'])) { if ($_GET['action'] === 'get_payment_details') { header('Content-Type: application/json'); $payment_id = (int)$_GET['payment_id']; - $stmt = db()->prepare("SELECT p.*, i.id as inv_id, c.name as customer_name + $stmt = db()->prepare("SELECT p.*, i.id as inv_id, i.type as inv_type, c.name as customer_name FROM payments p JOIN invoices i ON p.invoice_id = i.id LEFT JOIN customers c ON i.customer_id = c.id @@ -86,10 +86,131 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($name) { $stmt = db()->prepare("INSERT INTO customers (name, email, phone, tax_id, balance, type) VALUES (?, ?, ?, ?, ?, ?)"); $stmt->execute([$name, $email, $phone, $tax_id, $balance, $type]); + if (isset($_POST['ajax'])) { + echo json_encode(['success' => true, 'id' => db()->lastInsertId(), 'name' => $name]); + exit; + } $message = ucfirst($type) . " added successfully!"; } } + if (isset($_POST['action']) && $_POST['action'] === 'save_pos_transaction') { + header('Content-Type: application/json'); + try { + $db = db(); + $db->beginTransaction(); + + $customer_id = !empty($_POST['customer_id']) ? (int)$_POST['customer_id'] : null; + $payment_method = $_POST['payment_method'] ?? 'cash'; + $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); + + $net_amount = $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); + + // 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]); + $invoice_id = $db->lastInsertId(); + + // 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]); + $pos_id = $db->lastInsertId(); + + foreach ($items as $item) { + // 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']]); + + // 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']]); + + // Update stock + $stmt = $db->prepare("UPDATE stock_items SET stock_quantity = stock_quantity - ? WHERE id = ?"); + $stmt->execute([$item['qty'], $item['id']]); + } + + // Update Customer Loyalty Points + if ($customer_id) { + $stmt = $db->prepare("UPDATE customers SET loyalty_points = loyalty_points - ? + ? WHERE id = ?"); + $stmt->execute([$loyalty_redeemed, $loyalty_earned, $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]); + + $db->commit(); + echo json_encode(['success' => true, 'invoice_id' => $invoice_id, 'transaction_no' => $transaction_no]); + } catch (Exception $e) { + $db->rollBack(); + echo json_encode(['success' => false, 'error' => $e->getMessage()]); + } + exit; + } + + // New Handlers for Advanced POS Features + if (isset($_POST['action']) && $_POST['action'] === 'hold_pos_cart') { + header('Content-Type: application/json'); + $name = $_POST['cart_name'] ?? 'Cart ' . date('H:i'); + $items = $_POST['items'] ?? '[]'; + $customer_id = !empty($_POST['customer_id']) ? (int)$_POST['customer_id'] : null; + + $stmt = db()->prepare("INSERT INTO pos_held_carts (cart_name, items_json, customer_id) VALUES (?, ?, ?)"); + $stmt->execute([$name, $items, $customer_id]); + echo json_encode(['success' => true]); + exit; + } + + if (isset($_GET['action']) && $_GET['action'] === 'get_held_carts') { + header('Content-Type: application/json'); + $stmt = db()->query("SELECT h.*, c.name as customer_name FROM pos_held_carts h LEFT JOIN customers c ON h.customer_id = c.id ORDER BY h.id DESC"); + echo json_encode($stmt->fetchAll(PDO::FETCH_ASSOC)); + exit; + } + + if (isset($_POST['action']) && $_POST['action'] === 'delete_held_cart') { + header('Content-Type: application/json'); + $id = (int)$_POST['id']; + $stmt = db()->prepare("DELETE FROM pos_held_carts WHERE id = ?"); + $stmt->execute([$id]); + echo json_encode(['success' => true]); + exit; + } + + if (isset($_GET['action']) && $_GET['action'] === 'validate_discount') { + header('Content-Type: application/json'); + $code = $_GET['code'] ?? ''; + $stmt = db()->prepare("SELECT * FROM discount_codes WHERE code = ? AND status = 'active' AND (expiry_date IS NULL OR expiry_date >= CURDATE())"); + $stmt->execute([$code]); + $discount = $stmt->fetch(PDO::FETCH_ASSOC); + if ($discount) { + echo json_encode(['success' => true, 'discount' => $discount]); + } else { + echo json_encode(['success' => false, 'error' => 'Invalid or expired code']); + } + exit; + } + + if (isset($_GET['action']) && $_GET['action'] === 'get_customer_loyalty') { + header('Content-Type: application/json'); + $id = (int)($_GET['customer_id'] ?? 0); + $stmt = db()->prepare("SELECT loyalty_points FROM customers WHERE id = ?"); + $stmt->execute([$id]); + $points = $stmt->fetchColumn(); + echo json_encode(['success' => true, 'points' => (float)$points]); + exit; + } + if (isset($_POST['edit_customer'])) { $id = (int)$_POST['id']; $name = $_POST['name'] ?? ''; @@ -372,7 +493,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } $total_with_vat = $subtotal + $total_vat; - $paid_amount = ($status === 'paid') ? $total_with_vat : 0; + $paid_amount = ($status === 'paid') ? $total_with_vat : (($status === 'unpaid') ? 0 : (float)($_POST['paid_amount'] ?? 0)); $stmt = $db->prepare("INSERT INTO invoices (customer_id, invoice_date, type, payment_type, status, total_amount, vat_amount, total_with_vat, paid_amount) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"); $stmt->execute([$customer_id, $invoice_date, $type, $payment_type, $status, $subtotal, $total_vat, $total_with_vat, $paid_amount]); @@ -829,6 +950,45 @@ switch ($page) { $data['items_list'] = db()->query("SELECT id, name_en, name_ar, sale_price, purchase_price, stock_quantity, vat_rate FROM stock_items ORDER BY name_en ASC")->fetchAll(); $data['customers_list'] = db()->query("SELECT id, name FROM customers WHERE type = '" . ($type === 'sale' ? 'customer' : 'supplier') . "' ORDER BY name ASC")->fetchAll(); break; + + case 'customer_statement': + case 'supplier_statement': + $type = ($page === 'customer_statement') ? 'customer' : 'supplier'; + $invType = ($type === 'customer') ? 'sale' : 'purchase'; + $data['entities'] = db()->query("SELECT id, name, balance FROM customers WHERE type = '$type' ORDER BY name ASC")->fetchAll(); + + $entity_id = (int)($_GET['entity_id'] ?? 0); + if ($entity_id) { + $data['selected_entity'] = db()->query("SELECT * FROM customers WHERE id = $entity_id")->fetch(); + $start_date = $_GET['start_date'] ?? date('Y-m-01'); + $end_date = $_GET['end_date'] ?? date('Y-m-d'); + + // Fetch Opening Balance (Balance before start_date) + // This is complex as we don't have a ledger table. + // We can calculate it: Initial Balance + Invoices(before start_date) - Payments(before start_date) + // But for now, let's just show all transactions if no date filter, or just transactions in range. + + $stmt = db()->prepare("SELECT 'invoice' as trans_type, id, invoice_date as trans_date, total_with_vat as amount, status, id as ref_no + FROM invoices + WHERE customer_id = ? AND type = ? AND invoice_date BETWEEN ? AND ?"); + $stmt->execute([$entity_id, $invType, $start_date, $end_date]); + $invoices = $stmt->fetchAll(PDO::FETCH_ASSOC); + + $stmt = db()->prepare("SELECT 'payment' as trans_type, p.id, p.payment_date as trans_date, p.amount, p.payment_method, p.invoice_id as ref_no + FROM payments p + JOIN invoices i ON p.invoice_id = i.id + WHERE i.customer_id = ? AND i.type = ? AND p.payment_date BETWEEN ? AND ?"); + $stmt->execute([$entity_id, $invType, $start_date, $end_date]); + $payments = $stmt->fetchAll(PDO::FETCH_ASSOC); + + $transactions = array_merge($invoices, $payments); + usort($transactions, function($a, $b) { + return strtotime($a['trans_date']) <=> strtotime($b['trans_date']); + }); + + $data['transactions'] = $transactions; + } + break; default: $data['customers'] = db()->query("SELECT * FROM customers WHERE type = 'customer' ORDER BY id DESC LIMIT 5")->fetchAll(); // Dashboard stats @@ -837,8 +997,11 @@ switch ($page) { 'total_items' => db()->query("SELECT COUNT(*) FROM stock_items")->fetchColumn(), 'total_sales' => db()->query("SELECT SUM(total_with_vat) FROM invoices WHERE type = 'sale'")->fetchColumn() ?: 0, 'total_received' => db()->query("SELECT SUM(amount) FROM payments p JOIN invoices i ON p.invoice_id = i.id WHERE i.type = 'sale'")->fetchColumn() ?: 0, + 'total_purchases' => db()->query("SELECT SUM(total_with_vat) FROM invoices WHERE type = 'purchase'")->fetchColumn() ?: 0, + 'total_paid' => db()->query("SELECT SUM(amount) FROM payments p JOIN invoices i ON p.invoice_id = i.id WHERE i.type = 'purchase'")->fetchColumn() ?: 0, ]; $data['stats']['total_receivable'] = $data['stats']['total_sales'] - $data['stats']['total_received']; + $data['stats']['total_payable'] = $data['stats']['total_purchases'] - $data['stats']['total_paid']; break; } @@ -857,6 +1020,7 @@ $projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Accounting System'; +