diff --git a/assets/css/custom.css b/assets/css/custom.css
index a503557..bb6f174 100644
--- a/assets/css/custom.css
+++ b/assets/css/custom.css
@@ -235,6 +235,10 @@ body {
border-bottom: 2px solid var(--border);
}
+.op-50 {
+ opacity: 0.5;
+}
+
/* RTL Adjustments */
[dir="rtl"] .ms-auto {
margin-right: auto !important;
diff --git a/assets/js/main.js b/assets/js/main.js
index d809544..15b0542 100644
--- a/assets/js/main.js
+++ b/assets/js/main.js
@@ -20,7 +20,30 @@ function setLanguage(lang) {
// Update UI text
document.querySelectorAll('[data-en]').forEach(el => {
- el.textContent = el.getAttribute(`data-${lang}`);
+ const text = el.getAttribute(`data-${lang}`);
+ if (text) {
+ // If it's a simple element with only text, update it
+ // If it has children, we should only update the text node parts
+ // But for simplicity in this app, we'll check if it has any children
+ if (el.children.length === 0) {
+ el.textContent = text;
+ } else {
+ // If it has children (like icons), we try to find a text node to update
+ // or just update the first text node child
+ let found = false;
+ for (let node of el.childNodes) {
+ if (node.nodeType === 3) { // Text node
+ node.textContent = text;
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
+ // Fallback: append text if no text node exists
+ el.appendChild(document.createTextNode(text));
+ }
+ }
+ }
});
// Update buttons/inputs
diff --git a/db/migrations/20260217_sales_returns.sql b/db/migrations/20260217_sales_returns.sql
new file mode 100644
index 0000000..c8a8564
--- /dev/null
+++ b/db/migrations/20260217_sales_returns.sql
@@ -0,0 +1,23 @@
+-- Migration: Add Sales Returns tables
+CREATE TABLE IF NOT EXISTS sales_returns (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ invoice_id INT NOT NULL,
+ customer_id INT NULL,
+ return_date DATE NOT NULL,
+ total_amount DECIMAL(15, 3) NOT NULL DEFAULT 0,
+ notes TEXT,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE,
+ FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE SET NULL
+);
+
+CREATE TABLE IF NOT EXISTS sales_return_items (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ return_id INT NOT NULL,
+ item_id INT NOT NULL,
+ quantity DECIMAL(15, 2) NOT NULL,
+ unit_price DECIMAL(15, 3) NOT NULL,
+ total_price DECIMAL(15, 3) NOT NULL,
+ FOREIGN KEY (return_id) REFERENCES sales_returns(id) ON DELETE CASCADE,
+ FOREIGN KEY (item_id) REFERENCES stock_items(id) ON DELETE CASCADE
+);
diff --git a/index.php b/index.php
index 1195feb..7e49dab 100644
--- a/index.php
+++ b/index.php
@@ -104,6 +104,17 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['action'])) {
echo json_encode(['success' => true, 'points' => (float)$points]);
exit;
}
+ if ($_GET['action'] === 'get_invoice_items') {
+ header('Content-Type: application/json');
+ $invoice_id = (int)$_GET['invoice_id'];
+ $stmt = db()->prepare("SELECT ii.*, i.name_en, i.name_ar, i.sku
+ FROM invoice_items ii
+ JOIN stock_items i ON ii.item_id = i.id
+ WHERE ii.invoice_id = ?");
+ $stmt->execute([$invoice_id]);
+ echo json_encode($stmt->fetchAll());
+ exit;
+ }
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
@@ -1046,6 +1057,72 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
}
}
+
+ if (isset($_POST['add_sales_return'])) {
+ $invoice_id = (int)$_POST['invoice_id'];
+ $return_date = $_POST['return_date'] ?: date('Y-m-d');
+ $notes = $_POST['notes'] ?? '';
+ $item_ids = $_POST['item_ids'] ?? [];
+ $quantities = $_POST['quantities'] ?? [];
+ $prices = $_POST['prices'] ?? [];
+
+ if ($invoice_id && !empty($item_ids)) {
+ $db = db();
+ $db->beginTransaction();
+ try {
+ // Get customer ID from invoice
+ $stmt = $db->prepare("SELECT customer_id FROM invoices WHERE id = ?");
+ $stmt->execute([$invoice_id]);
+ $customer_id = $stmt->fetchColumn();
+
+ $total_return_amount = 0;
+ $items_data = [];
+ foreach ($item_ids as $index => $item_id) {
+ $qty = (float)$quantities[$index];
+ $price = (float)$prices[$index];
+ if ($qty <= 0) continue;
+
+ $line_total = $qty * $price;
+ $total_return_amount += $line_total;
+
+ $items_data[] = [
+ 'id' => $item_id,
+ 'qty' => $qty,
+ 'price' => $price,
+ 'total' => $line_total
+ ];
+ }
+
+ if (empty($items_data)) throw new Exception("No items to return");
+
+ // Create Sales Return record
+ $stmt = $db->prepare("INSERT INTO sales_returns (invoice_id, customer_id, return_date, total_amount, notes) VALUES (?, ?, ?, ?, ?)");
+ $stmt->execute([$invoice_id, $customer_id, $return_date, $total_return_amount, $notes]);
+ $return_id = $db->lastInsertId();
+
+ foreach ($items_data as $item) {
+ $stmt = $db->prepare("INSERT INTO sales_return_items (return_id, item_id, quantity, unit_price, total_price) VALUES (?, ?, ?, ?, ?)");
+ $stmt->execute([$return_id, $item['id'], $item['qty'], $item['price'], $item['total']]);
+
+ // Restore stock
+ $stmt = $db->prepare("UPDATE stock_items SET stock_quantity = stock_quantity + ? WHERE id = ?");
+ $stmt->execute([$item['qty'], $item['id']]);
+ }
+
+ // If it was a credit sale, we might want to reduce the customer balance.
+ if ($customer_id) {
+ $stmt = $db->prepare("UPDATE customers SET balance = balance + ? WHERE id = ?");
+ $stmt->execute([$total_return_amount, $customer_id]);
+ }
+
+ $db->commit();
+ $message = "Sales Return #$return_id processed successfully!";
+ } catch (Exception $e) {
+ if (isset($db)) $db->rollBack();
+ $message = "Error: " . $e->getMessage();
+ }
+ }
+ }
}
@@ -1252,6 +1329,27 @@ switch ($page) {
$data['customers_list'] = db()->query("SELECT id, name FROM customers WHERE type = '" . ($type === 'sale' ? 'customer' : 'supplier') . "' ORDER BY name ASC")->fetchAll();
break;
+ case 'sales_returns':
+ $where = ["1=1"];
+ $params = [];
+ if (!empty($_GET['search'])) {
+ $where[] = "(sr.id LIKE ? OR c.name LIKE ? OR sr.invoice_id LIKE ?)";
+ $params[] = "%{$_GET['search']}%";
+ $params[] = "%{$_GET['search']}%";
+ $params[] = "%{$_GET['search']}%";
+ }
+ $whereSql = implode(" AND ", $where);
+ $stmt = db()->prepare("SELECT sr.*, c.name as customer_name, i.total_with_vat as invoice_total
+ FROM sales_returns sr
+ LEFT JOIN customers c ON sr.customer_id = c.id
+ LEFT JOIN invoices i ON sr.invoice_id = i.id
+ WHERE $whereSql
+ ORDER BY sr.id DESC");
+ $stmt->execute($params);
+ $data['returns'] = $stmt->fetchAll();
+ $data['sales_invoices'] = db()->query("SELECT id, invoice_date, total_with_vat FROM invoices WHERE type = 'sale' ORDER BY id DESC")->fetchAll();
+ break;
+
case 'customer_statement':
case 'supplier_statement':
$type = ($page === 'customer_statement') ? 'customer' : 'supplier';
@@ -1335,6 +1433,25 @@ switch ($page) {
$stmt->execute([$start_date, $end_date]);
$data['total_expenses'] = $stmt->fetchColumn() ?: 0;
break;
+ case 'expiry_report':
+ $where = ["expiry_date IS NOT NULL"];
+ $params = [];
+ $filter = $_GET['filter'] ?? 'all';
+ if ($filter === 'expired') {
+ $where[] = "expiry_date <= CURDATE()";
+ } elseif ($filter === 'near_expiry') {
+ $where[] = "expiry_date > CURDATE() AND expiry_date <= DATE_ADD(CURDATE(), INTERVAL 30 DAY)";
+ }
+
+ $whereSql = implode(" AND ", $where);
+ $stmt = db()->prepare("SELECT i.*, c.name_en as cat_en, c.name_ar as cat_ar
+ FROM stock_items i
+ LEFT JOIN stock_categories c ON i.category_id = c.id
+ WHERE $whereSql
+ ORDER BY i.expiry_date ASC");
+ $stmt->execute($params);
+ $data['expiry_items'] = $stmt->fetchAll();
+ break;
default:
$data['customers'] = db()->query("SELECT * FROM customers WHERE type = 'customer' ORDER BY id DESC LIMIT 5")->fetchAll();
// Dashboard stats
@@ -1345,9 +1462,15 @@ switch ($page) {
'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,
+ 'expired_items' => db()->query("SELECT COUNT(*) FROM stock_items WHERE expiry_date IS NOT NULL AND expiry_date <= CURDATE()")->fetchColumn(),
+ 'near_expiry_items' => db()->query("SELECT COUNT(*) FROM stock_items WHERE expiry_date IS NOT NULL AND expiry_date > CURDATE() AND expiry_date <= DATE_ADD(CURDATE(), INTERVAL 30 DAY)")->fetchColumn(),
];
$data['stats']['total_receivable'] = $data['stats']['total_sales'] - $data['stats']['total_received'];
$data['stats']['total_payable'] = $data['stats']['total_purchases'] - $data['stats']['total_paid'];
+
+ // Sales Chart Data
+ $data['monthly_sales'] = db()->query("SELECT DATE_FORMAT(invoice_date, '%M %Y') as label, SUM(total_with_vat) as total FROM invoices WHERE type = 'sale' GROUP BY DATE_FORMAT(invoice_date, '%Y-%m') ORDER BY invoice_date ASC LIMIT 12")->fetchAll(PDO::FETCH_ASSOC);
+ $data['yearly_sales'] = db()->query("SELECT YEAR(invoice_date) as label, SUM(total_with_vat) as total FROM invoices WHERE type = 'sale' GROUP BY label ORDER BY label ASC LIMIT 5")->fetchAll(PDO::FETCH_ASSOC);
break;
}
@@ -1368,9 +1491,11 @@ $projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Accounting System';
+
+