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';
+