From abc4505db8ea2dcf107646dde6ef6a19411cd3df Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Sun, 3 May 2026 04:54:16 +0000 Subject: [PATCH] Autosave: 20260503-045416 --- index.php | 283 +++++++++++++++++++++++----- pages/language_dashboard_script.php | 105 ++++++----- 2 files changed, 304 insertions(+), 84 deletions(-) diff --git a/index.php b/index.php index 263d782..1a616b1 100644 --- a/index.php +++ b/index.php @@ -197,6 +197,117 @@ if (!function_exists('db_insert_sql_for_existing_columns')) { } } +if (!function_exists('current_outlet_name')) { + function current_outlet_name(): string { + static $cache = []; + + $oid = current_outlet_id(); + if ($oid === -1 || $oid === 0) { + return __('All Outlets') ?: 'All Outlets'; + } + + if (array_key_exists($oid, $cache)) { + return $cache[$oid]; + } + + if (!db_table_exists('outlets')) { + $cache[$oid] = 'Outlet ' . $oid; + return $cache[$oid]; + } + + try { + $stmt = db()->prepare("SELECT name FROM outlets WHERE id = ? LIMIT 1"); + $stmt->execute([$oid]); + $cache[$oid] = (string)($stmt->fetchColumn() ?: ('Outlet ' . $oid)); + } catch (Throwable $e) { + $cache[$oid] = 'Outlet ' . $oid; + } + + return $cache[$oid]; + } +} + +if (!function_exists('outlet_scope_sql')) { + function outlet_scope_sql(string $tableName, string $qualifiedColumn = 'outlet_id', bool $includeLegacyNull = true): array { + if (!db_column_exists($tableName, 'outlet_id')) { + return ['sql' => '1=1', 'params' => []]; + } + + $oid = current_outlet_id(); + if ($oid === -1) { + return ['sql' => '1=1', 'params' => []]; + } + + $sql = $includeLegacyNull + ? "({$qualifiedColumn} = ? OR {$qualifiedColumn} IS NULL)" + : "{$qualifiedColumn} = ?"; + + return ['sql' => $sql, 'params' => [$oid]]; + } +} + +if (!function_exists('dashboard_sales_series')) { + function dashboard_sales_series(string $period = 'month', int $limit = 12): array { + $period = strtolower($period) === 'year' ? 'year' : 'month'; + $limit = max(1, (int)$limit); + $sources = []; + $params = []; + $db = db(); + + if (db_table_exists('invoices')) { + $invoiceDateColumn = db_first_existing_column('invoices', ['invoice_date', 'created_at']); + $invoiceTotalExpression = db_column_exists('invoices', 'total_with_vat') + ? 'COALESCE(total_with_vat, 0)' + : (db_column_exists('invoices', 'total_amount') ? 'COALESCE(total_amount, 0)' : '0'); + + if ($invoiceDateColumn !== null) { + $invoiceScope = outlet_scope_sql('invoices', 'outlet_id'); + if ($period === 'year') { + $sources[] = "SELECT YEAR(`{$invoiceDateColumn}`) AS period_key, CAST(YEAR(`{$invoiceDateColumn}`) AS CHAR) AS label, {$invoiceTotalExpression} AS total FROM invoices WHERE `{$invoiceDateColumn}` IS NOT NULL AND {$invoiceScope['sql']}"; + } else { + $sources[] = "SELECT DATE_FORMAT(`{$invoiceDateColumn}`, '%Y-%m') AS period_key, DATE_FORMAT(`{$invoiceDateColumn}`, '%b %Y') AS label, {$invoiceTotalExpression} AS total FROM invoices WHERE `{$invoiceDateColumn}` IS NOT NULL AND {$invoiceScope['sql']}"; + } + $params = array_merge($params, $invoiceScope['params']); + } + } + + if (db_table_exists('pos_transactions')) { + $posDateColumn = db_first_existing_column('pos_transactions', ['created_at', 'transaction_date', 'sale_date']); + $posTotalExpression = db_column_exists('pos_transactions', 'net_amount') + ? 'COALESCE(net_amount, 0)' + : (db_column_exists('pos_transactions', 'total_amount') ? 'COALESCE(total_amount, 0)' : '0'); + + if ($posDateColumn !== null) { + $posScope = outlet_scope_sql('pos_transactions', 'outlet_id'); + $posStatusPredicate = db_column_exists('pos_transactions', 'status') ? "status = 'completed' AND " : ''; + if ($period === 'year') { + $sources[] = "SELECT YEAR(`{$posDateColumn}`) AS period_key, CAST(YEAR(`{$posDateColumn}`) AS CHAR) AS label, {$posTotalExpression} AS total FROM pos_transactions WHERE {$posStatusPredicate}`{$posDateColumn}` IS NOT NULL AND {$posScope['sql']}"; + } else { + $sources[] = "SELECT DATE_FORMAT(`{$posDateColumn}`, '%Y-%m') AS period_key, DATE_FORMAT(`{$posDateColumn}`, '%b %Y') AS label, {$posTotalExpression} AS total FROM pos_transactions WHERE {$posStatusPredicate}`{$posDateColumn}` IS NOT NULL AND {$posScope['sql']}"; + } + $params = array_merge($params, $posScope['params']); + } + } + + if ($sources === []) { + return []; + } + + $sql = "SELECT period_key, label, SUM(total) AS total FROM (" . implode(' UNION ALL ', $sources) . ") dashboard_sales_rollup GROUP BY period_key, label ORDER BY period_key DESC LIMIT {$limit}"; + $stmt = $db->prepare($sql); + $stmt->execute($params); + $rows = array_reverse($stmt->fetchAll(PDO::FETCH_ASSOC)); + + foreach ($rows as &$row) { + $row['label'] = (string)($row['label'] ?? ''); + $row['total'] = (float)($row['total'] ?? 0); + } + unset($row); + + return $rows; + } +} + if (!function_exists('sales_return_reference_column')) { function sales_return_reference_column(): string { return db_first_existing_column('sales_returns', ['invoice_id', 'sale_id']) ?? 'invoice_id'; @@ -629,26 +740,41 @@ function getPurchaseAlerts() { $db = db(); $hasSupplierJoin = db_table_exists('suppliers') && db_column_exists('purchases', 'supplier_id'); $dueDateExpression = db_column_exists('purchases', 'due_date') ? 'p.due_date' : 'NULL'; - $statusPredicate = db_column_exists('purchases', 'status') ? "WHERE p.status != 'paid'" : 'WHERE 1=1'; - $dueDatePredicate = db_column_exists('purchases', 'due_date') - ? ' AND p.due_date IS NOT NULL AND p.due_date <= DATE_ADD(CURDATE(), INTERVAL 7 DAY)' - : ''; $totalExpression = db_column_exists('purchases', 'total_with_vat') - ? 'p.total_with_vat' + ? 'COALESCE(p.total_with_vat, 0)' : (db_column_exists('purchases', 'total_amount') ? 'COALESCE(p.total_amount, 0)' : '0'); $supplierExpression = $hasSupplierJoin ? 's.name' : 'NULL'; $joinClause = $hasSupplierJoin ? ' LEFT JOIN suppliers s ON p.supplier_id = s.id' : ''; $orderBy = db_column_exists('purchases', 'due_date') ? ' ORDER BY p.due_date ASC' : ' ORDER BY p.id DESC'; + $where = []; + $params = []; + + if (db_column_exists('purchases', 'status')) { + $where[] = "p.status != 'paid'"; + } + + if (db_column_exists('purchases', 'due_date')) { + $where[] = 'p.due_date IS NOT NULL'; + $where[] = 'p.due_date <= DATE_ADD(CURDATE(), INTERVAL 7 DAY)'; + } + + $outletScope = outlet_scope_sql('purchases', 'p.outlet_id'); + if ($outletScope['sql'] !== '1=1') { + $where[] = $outletScope['sql']; + $params = array_merge($params, $outletScope['params']); + } + + $whereSql = $where === [] ? '1=1' : implode(' AND ', $where); $sql = "SELECT p.id, {$dueDateExpression} AS due_date, {$totalExpression} AS total_with_vat, {$supplierExpression} AS supplier_name" . ' FROM purchases p' . $joinClause - . ' ' - . $statusPredicate - . $dueDatePredicate + . ' WHERE ' + . $whereSql . $orderBy; - $stmt = $db->query($sql); + $stmt = $db->prepare($sql); + $stmt->execute($params); return $stmt->fetchAll(PDO::FETCH_ASSOC); } @@ -3822,6 +3948,7 @@ $data = [ 'cash_transactions' => [], 'monthly_sales' => [], 'yearly_sales' => [], + 'dashboard_scope_label' => '', 'opening_balance' => 0, 'stats' => [ 'expired_items' => 0, @@ -3831,6 +3958,10 @@ $data = [ 'total_received' => 0, 'total_receivable' => 0, 'total_purchases' => 0, + 'total_paid' => 0, + 'total_payable' => 0, + 'total_customers' => 0, + 'total_items' => 0, ], 'settings' => [], ]; @@ -4708,39 +4839,99 @@ switch ($page) { break; default: if (can('dashboard_view')) { - $data['customers'] = db()->query("SELECT * FROM customers ORDER BY id DESC LIMIT 5")->fetchAll(); - // Statistics with Outlet Filter - $current_oid = current_outlet_id(); - $inv_cond = ($current_oid > 0) ? " WHERE outlet_id = $current_oid " : ""; - $pos_cond = " WHERE status = 'completed' " . (($current_oid > 0) ? " AND outlet_id = $current_oid " : ""); - - $pay_inv_cond = ($current_oid > 0) ? " WHERE i.outlet_id = $current_oid " : ""; - $pay_pos_cond = ($current_oid > 0) ? " WHERE t.outlet_id = $current_oid " : ""; - - $pur_cond = ($current_oid > 0) ? " WHERE outlet_id = $current_oid " : ""; - $pay_pur_cond = ($current_oid > 0) ? " WHERE p.outlet_id = $current_oid " : ""; + $db = db(); + $scalar = static function (string $sql, array $params = []) use ($db) { + $stmt = $db->prepare($sql); + $stmt->execute($params); + return $stmt->fetchColumn(); + }; - $low_stock_query = ($current_oid > 0) - ? "SELECT COUNT(*) FROM stock_items WHERE outlet_id = $current_oid AND stock_quantity <= min_stock_level" - : "SELECT COUNT(*) FROM stock_items WHERE stock_quantity <= min_stock_level"; + $data['dashboard_scope_label'] = current_outlet_name(); - $data['stats'] = [ - 'total_customers' => db()->query("SELECT COUNT(*) FROM customers")->fetchColumn(), - 'total_items' => db()->query("SELECT COUNT(*) FROM stock_items")->fetchColumn(), - 'total_sales' => (db()->query("SELECT SUM(total_with_vat) FROM invoices $inv_cond")->fetchColumn() ?: 0) + (db()->query("SELECT SUM(net_amount) FROM pos_transactions $pos_cond")->fetchColumn() ?: 0), - 'total_received' => (db()->query("SELECT SUM(p.amount) FROM payments p JOIN invoices i ON p.invoice_id = i.id $pay_inv_cond")->fetchColumn() ?: 0) + (db()->query("SELECT SUM(pp.amount) FROM pos_payments pp JOIN pos_transactions t ON pp.transaction_id = t.id $pay_pos_cond")->fetchColumn() ?: 0), - 'total_purchases' => db()->query("SELECT SUM(total_with_vat) FROM purchases $pur_cond")->fetchColumn() ?: 0, - 'total_paid' => db()->query("SELECT SUM(pp.amount) FROM purchase_payments pp JOIN purchases p ON pp.purchase_id = p.id $pay_pur_cond")->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(), - 'low_stock_items_count' => db()->query($low_stock_query)->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']; + $customerScope = outlet_scope_sql('customers', 'outlet_id'); + if (db_table_exists('customers')) { + $customerStmt = $db->prepare("SELECT * FROM customers WHERE {$customerScope['sql']} ORDER BY id DESC LIMIT 5"); + $customerStmt->execute($customerScope['params']); + $data['customers'] = $customerStmt->fetchAll(); + $data['stats']['total_customers'] = (int)($scalar("SELECT COUNT(*) FROM customers WHERE {$customerScope['sql']}", $customerScope['params']) ?: 0); + } - // 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 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 GROUP BY label ORDER BY label ASC LIMIT 5")->fetchAll(PDO::FETCH_ASSOC); + $itemScope = outlet_scope_sql('stock_items', 'outlet_id'); + if (db_table_exists('stock_items')) { + $data['stats']['total_items'] = (int)($scalar("SELECT COUNT(*) FROM stock_items WHERE {$itemScope['sql']}", $itemScope['params']) ?: 0); + + if (db_column_exists('stock_items', 'expiry_date')) { + $data['stats']['expired_items'] = (int)($scalar("SELECT COUNT(*) FROM stock_items WHERE expiry_date IS NOT NULL AND expiry_date <= CURDATE() AND {$itemScope['sql']}", $itemScope['params']) ?: 0); + $data['stats']['near_expiry_items'] = (int)($scalar("SELECT COUNT(*) FROM stock_items WHERE expiry_date IS NOT NULL AND expiry_date > CURDATE() AND expiry_date <= DATE_ADD(CURDATE(), INTERVAL 30 DAY) AND {$itemScope['sql']}", $itemScope['params']) ?: 0); + } + + if (db_column_exists('stock_items', 'stock_quantity') && db_column_exists('stock_items', 'min_stock_level')) { + $data['stats']['low_stock_items_count'] = (int)($scalar("SELECT COUNT(*) FROM stock_items WHERE stock_quantity <= min_stock_level AND {$itemScope['sql']}", $itemScope['params']) ?: 0); + } + } + + if (db_table_exists('invoices')) { + $invoiceScope = outlet_scope_sql('invoices', 'outlet_id'); + $invoiceTotalExpression = db_column_exists('invoices', 'total_with_vat') ? 'COALESCE(total_with_vat, 0)' : (db_column_exists('invoices', 'total_amount') ? 'COALESCE(total_amount, 0)' : '0'); + $data['stats']['total_sales'] += (float)($scalar("SELECT COALESCE(SUM({$invoiceTotalExpression}), 0) FROM invoices WHERE {$invoiceScope['sql']}", $invoiceScope['params']) ?: 0); + } + + if (db_table_exists('pos_transactions')) { + $posScope = outlet_scope_sql('pos_transactions', 'outlet_id'); + $posTotalExpression = db_column_exists('pos_transactions', 'net_amount') ? 'COALESCE(net_amount, 0)' : (db_column_exists('pos_transactions', 'total_amount') ? 'COALESCE(total_amount, 0)' : '0'); + $posWhere = []; + $posParams = []; + if (db_column_exists('pos_transactions', 'status')) { + $posWhere[] = 'status = ?'; + $posParams[] = 'completed'; + } + if ($posScope['sql'] !== '1=1') { + $posWhere[] = $posScope['sql']; + $posParams = array_merge($posParams, $posScope['params']); + } + $posWhereSql = $posWhere === [] ? '1=1' : implode(' AND ', $posWhere); + $data['stats']['total_sales'] += (float)($scalar("SELECT COALESCE(SUM({$posTotalExpression}), 0) FROM pos_transactions WHERE {$posWhereSql}", $posParams) ?: 0); + } + + if (db_table_exists('payments') && db_table_exists('invoices') && db_column_exists('payments', 'invoice_id')) { + $paymentInvoiceScope = outlet_scope_sql('invoices', 'i.outlet_id'); + $paymentInvoiceWhere = $paymentInvoiceScope['sql'] === '1=1' ? '1=1' : $paymentInvoiceScope['sql']; + $data['stats']['total_received'] += (float)($scalar("SELECT COALESCE(SUM(p.amount), 0) FROM payments p JOIN invoices i ON p.invoice_id = i.id WHERE {$paymentInvoiceWhere}", $paymentInvoiceScope['params']) ?: 0); + } + + if (db_table_exists('pos_payments') && db_table_exists('pos_transactions') && db_column_exists('pos_payments', 'transaction_id')) { + $paymentPosScope = outlet_scope_sql('pos_transactions', 't.outlet_id'); + $paymentPosWhere = []; + $paymentPosParams = []; + if (db_column_exists('pos_transactions', 'status')) { + $paymentPosWhere[] = 't.status = ?'; + $paymentPosParams[] = 'completed'; + } + if ($paymentPosScope['sql'] !== '1=1') { + $paymentPosWhere[] = $paymentPosScope['sql']; + $paymentPosParams = array_merge($paymentPosParams, $paymentPosScope['params']); + } + $paymentPosWhereSql = $paymentPosWhere === [] ? '1=1' : implode(' AND ', $paymentPosWhere); + $data['stats']['total_received'] += (float)($scalar("SELECT COALESCE(SUM(pp.amount), 0) FROM pos_payments pp JOIN pos_transactions t ON pp.transaction_id = t.id WHERE {$paymentPosWhereSql}", $paymentPosParams) ?: 0); + } + + if (db_table_exists('purchases')) { + $purchaseScope = outlet_scope_sql('purchases', 'outlet_id'); + $purchaseTotalExpression = db_column_exists('purchases', 'total_with_vat') ? 'COALESCE(total_with_vat, 0)' : (db_column_exists('purchases', 'total_amount') ? 'COALESCE(total_amount, 0)' : '0'); + $data['stats']['total_purchases'] = (float)($scalar("SELECT COALESCE(SUM({$purchaseTotalExpression}), 0) FROM purchases WHERE {$purchaseScope['sql']}", $purchaseScope['params']) ?: 0); + } + + if (db_table_exists('purchase_payments') && db_table_exists('purchases') && db_column_exists('purchase_payments', 'purchase_id')) { + $purchasePaymentScope = outlet_scope_sql('purchases', 'p.outlet_id'); + $purchasePaymentWhere = $purchasePaymentScope['sql'] === '1=1' ? '1=1' : $purchasePaymentScope['sql']; + $data['stats']['total_paid'] = (float)($scalar("SELECT COALESCE(SUM(pp.amount), 0) FROM purchase_payments pp JOIN purchases p ON pp.purchase_id = p.id WHERE {$purchasePaymentWhere}", $purchasePaymentScope['params']) ?: 0); + } + + $data['stats']['total_receivable'] = max((float)$data['stats']['total_sales'] - (float)$data['stats']['total_received'], 0); + $data['stats']['total_payable'] = max((float)$data['stats']['total_purchases'] - (float)$data['stats']['total_paid'], 0); + + $data['monthly_sales'] = dashboard_sales_series('month', 12); + $data['yearly_sales'] = dashboard_sales_series('year', 5); } break; } @@ -5598,7 +5789,7 @@ $projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Accounting System'; if (count($user_outlets_list) > 1 || $is_admin): $current_oid = current_outlet_id(); - $current_oname = $current_oid === -1 ? (__('All Outlets') ?: 'All Outlets') : (db()->query("SELECT name FROM outlets WHERE id = $current_oid")->fetchColumn() ?: 'Outlet ' . $current_oid); + $current_oname = current_outlet_name(); ?> diff --git a/pages/language_dashboard_script.php b/pages/language_dashboard_script.php index 11307a7..f1342f6 100644 --- a/pages/language_dashboard_script.php +++ b/pages/language_dashboard_script.php @@ -58,53 +58,74 @@ document.addEventListener('DOMContentLoaded', function() { }); // ----------------------------- -const monthlyData = ; -const yearlyData = ; +const normalizeSalesSeries = (series) => Array.isArray(series) + ? series.map(point => ({ + label: String(point.label ?? ''), + total: Number(point.total ?? 0) + })) + : []; -const ctx = document.getElementById('salesChart').getContext('2d'); -let salesChart = new Chart(ctx, { - type: 'line', - data: { - labels: monthlyData.map(d => d.label), - datasets: [{ - label: 'Sales (OMR)', - data: monthlyData.map(d => d.total), - borderColor: '#0d6efd', - backgroundColor: 'rgba(13, 110, 253, 0.1)', - fill: true, - tension: 0.4 - }] - }, - options: { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { display: false } +const monthlyData = normalizeSalesSeries(); +const yearlyData = normalizeSalesSeries(); +const salesChartCanvas = document.getElementById('salesChart'); +const salesChartEmptyState = document.getElementById('salesChartEmptyState'); + +if (salesChartCanvas) { + const ctx = salesChartCanvas.getContext('2d'); + let salesChart = new Chart(ctx, { + type: 'line', + data: { + labels: monthlyData.map(d => d.label), + datasets: [{ + label: 'Sales (OMR)', + data: monthlyData.map(d => d.total), + borderColor: '#0d6efd', + backgroundColor: 'rgba(13, 110, 253, 0.1)', + fill: true, + tension: 0.4 + }] }, - scales: { - y: { - beginAtZero: true, - ticks: { - callback: function(value) { return 'OMR ' + value.toFixed(3); } + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: false } + }, + scales: { + y: { + beginAtZero: true, + ticks: { + callback: function(value) { + return 'OMR ' + Number(value).toFixed(3); + } + } } } } - } -}); + }); -document.getElementById('btnMonthly').addEventListener('click', function() { - this.classList.add('active'); - document.getElementById('btnYearly').classList.remove('active'); - salesChart.data.labels = monthlyData.map(d => d.label); - salesChart.data.datasets[0].data = monthlyData.map(d => d.total); - salesChart.update(); -}); + const renderSalesSeries = (series) => { + salesChart.data.labels = series.map(d => d.label); + salesChart.data.datasets[0].data = series.map(d => d.total); + salesChart.update(); -document.getElementById('btnYearly').addEventListener('click', function() { - this.classList.add('active'); - document.getElementById('btnMonthly').classList.remove('active'); - salesChart.data.labels = yearlyData.map(d => d.label); - salesChart.data.datasets[0].data = yearlyData.map(d => d.total); - salesChart.update(); -}); + if (salesChartEmptyState) { + salesChartEmptyState.classList.toggle('d-none', series.length > 0); + } + }; + + renderSalesSeries(monthlyData); + + document.getElementById('btnMonthly')?.addEventListener('click', function() { + this.classList.add('active'); + document.getElementById('btnYearly')?.classList.remove('active'); + renderSalesSeries(monthlyData); + }); + + document.getElementById('btnYearly')?.addEventListener('click', function() { + this.classList.add('active'); + document.getElementById('btnMonthly')?.classList.remove('active'); + renderSalesSeries(yearlyData); + }); +}