diff --git a/assets/css/custom.css b/assets/css/custom.css index a7832f4..1cece62 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -715,3 +715,94 @@ body:not(.theme-default) .form-select:focus { .form-grid-3 .input-group { width: 100%; } + + +/* Document list filters + table */ +.documents-filter { + display: flex; + align-items: flex-end; + gap: 0.75rem; + flex-wrap: nowrap; +} + +.documents-filter__field { + flex: 1 1 0; + min-width: 0; +} + +.documents-filter__field--search { + flex: 1.45 1 230px; +} + +.documents-filter__field--party { + flex: 1.2 1 210px; +} + +.documents-filter__field--date { + flex: 0 1 150px; +} + +.documents-filter__field .form-label { + margin-bottom: 0.35rem; +} + +.documents-filter__field .form-control, +.documents-filter__field .form-select { + width: 100%; +} + +.documents-filter__actions { + display: flex; + flex: 0 0 auto; + align-items: flex-end; + justify-content: flex-end; + gap: 0.5rem; + flex-wrap: wrap; + margin-left: auto; +} + +.documents-filter__actions .btn, +.documents-filter__actions .dropdown-toggle { + white-space: nowrap; +} + +.documents-table__party { + min-width: 190px; +} + +.documents-table__dates { + min-width: 148px; +} + +@media (max-width: 1399.98px) { + .documents-filter { + flex-wrap: wrap; + } + + .documents-filter__field--date { + flex: 1 1 165px; + } + + .documents-filter__actions { + width: 100%; + justify-content: flex-start; + margin-left: 0; + } +} + +@media (max-width: 767.98px) { + .documents-filter { + gap: 0.6rem; + } + + .documents-filter__field, + .documents-filter__actions { + flex: 1 1 100%; + width: 100%; + } + + .documents-filter__actions .btn, + .documents-filter__actions .dropdown { + flex: 1 1 calc(50% - 0.3rem); + } +} diff --git a/complete_schema.sql b/complete_schema.sql index f4b9367..ba7506d 100644 --- a/complete_schema.sql +++ b/complete_schema.sql @@ -316,6 +316,7 @@ CREATE TABLE IF NOT EXISTS `invoices` ( `transaction_no` varchar(50) DEFAULT NULL, `customer_id` int(11) DEFAULT NULL, `invoice_date` date NOT NULL, + `due_date` date DEFAULT NULL, `type` enum('sale','purchase') NOT NULL, `payment_type` varchar(100) DEFAULT NULL, `total_amount` decimal(15,3) DEFAULT 0.000, diff --git a/db/install_baseline_migrations.php b/db/install_baseline_migrations.php index 37572eb..dfebfda 100644 --- a/db/install_baseline_migrations.php +++ b/db/install_baseline_migrations.php @@ -50,6 +50,7 @@ return [ '20260502_stock_items_schema_sync.php', '20260502_zzz_payment_methods_schema_sync.php', '20260502_zz_financial_documents_schema_sync.php', + '20260503_zz_ensure_invoice_due_date.php', 'add_outlet_id.sql', 'fix_lpo_foreign_key.sql', ]; diff --git a/db/migrations/20260502_zz_financial_documents_schema_sync.php b/db/migrations/20260502_zz_financial_documents_schema_sync.php index be2145d..4a093ed 100644 --- a/db/migrations/20260502_zz_financial_documents_schema_sync.php +++ b/db/migrations/20260502_zz_financial_documents_schema_sync.php @@ -12,6 +12,7 @@ if (!function_exists('financial_documents_schema_sync_20260502_run')) { 'invoices' => [ 'transaction_no' => 'VARCHAR(50) DEFAULT NULL', 'payment_type' => 'VARCHAR(100) DEFAULT NULL', + 'due_date' => 'DATE DEFAULT NULL', 'vat_amount' => 'DECIMAL(15,3) DEFAULT 0.000', 'total_with_vat' => 'DECIMAL(15,3) DEFAULT 0.000', 'terms_conditions' => 'TEXT NULL', diff --git a/db/migrations/20260503_seed_chart_of_accounts.php b/db/migrations/20260503_seed_chart_of_accounts.php index f74fccd..67b45c3 100644 --- a/db/migrations/20260503_seed_chart_of_accounts.php +++ b/db/migrations/20260503_seed_chart_of_accounts.php @@ -4,6 +4,19 @@ declare(strict_types=1); require_once dirname(__DIR__) . '/config.php'; require_once dirname(__DIR__, 2) . '/includes/accounting_helper.php'; -if (function_exists('seedDefaultAccountingAccounts')) { - seedDefaultAccountingAccounts(); +if (function_exists('ensureAccountingSchema')) { + ensureAccountingSchema(); } + +$added = function_exists('seedDefaultAccountingAccounts') ? seedDefaultAccountingAccounts() : 0; +$total = 0; + +try { + $total = (int) db()->query('SELECT COUNT(*) FROM acc_accounts')->fetchColumn(); +} catch (Throwable $e) { + error_log('Chart of accounts count failed: ' . $e->getMessage()); +} + +echo $added > 0 + ? sprintf('Chart of accounts seeded successfully. Added %d account(s); total is now %d.', $added, $total) + : sprintf('Chart of accounts already up to date. Total accounts: %d.', $total); diff --git a/db/migrations/20260503_zz_ensure_invoice_due_date.php b/db/migrations/20260503_zz_ensure_invoice_due_date.php new file mode 100644 index 0000000..4d4408f --- /dev/null +++ b/db/migrations/20260503_zz_ensure_invoice_due_date.php @@ -0,0 +1,27 @@ +prepare( + 'SELECT 1 FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table LIMIT 1' +); +$tableStmt->execute(['table' => 'invoices']); + +if ((bool)$tableStmt->fetchColumn()) { + $columnStmt = $pdo->prepare( + 'SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table AND COLUMN_NAME = :column LIMIT 1' + ); + $columnStmt->execute([ + 'table' => 'invoices', + 'column' => 'due_date', + ]); + + if (!(bool)$columnStmt->fetchColumn()) { + $pdo->exec('ALTER TABLE `invoices` ADD COLUMN `due_date` DATE DEFAULT NULL AFTER `invoice_date`'); + } +} + +return true; diff --git a/db/schema.sql b/db/schema.sql index 1572283..7649ffe 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -359,6 +359,7 @@ CREATE TABLE `invoices` ( `id` int(11) NOT NULL AUTO_INCREMENT, `customer_id` int(11) DEFAULT NULL, `invoice_date` date NOT NULL, + `due_date` date DEFAULT NULL, `type` enum('sale','purchase') NOT NULL, `payment_type` varchar(100) DEFAULT NULL, `total_amount` decimal(15,3) DEFAULT 0.000, diff --git a/includes/accounting_helper.php b/includes/accounting_helper.php index 578c0e6..d669c27 100644 --- a/includes/accounting_helper.php +++ b/includes/accounting_helper.php @@ -3,10 +3,14 @@ * Accounting Helper for Automatic Journal Entries */ -function accountingTableExists(string $tableName): bool { +function accountingTableExists(string $tableName, bool $refresh = false): bool { static $cache = []; $normalized = strtolower($tableName); + if ($refresh) { + unset($cache[$normalized]); + } + if (array_key_exists($normalized, $cache)) { return $cache[$normalized]; } @@ -25,6 +29,50 @@ function accountingTableExists(string $tableName): bool { return $cache[$normalized]; } +function ensureAccountingSchema(): void { + $db = db(); + + $db->exec(<<exec(<<exec(<< '1000', 'name_en' => 'Assets', 'name_ar' => 'الأصول', 'type' => 'asset', 'parent_code' => null], @@ -59,7 +107,9 @@ function getDefaultAccountingAccounts(): array { } function seedDefaultAccountingAccounts(): int { - if (!accountingTableExists('acc_accounts')) { + ensureAccountingSchema(); + + if (!accountingTableExists('acc_accounts', true)) { return 0; } @@ -120,7 +170,9 @@ function seedDefaultAccountingAccounts(): int { } function createAccountingAccount(string $code, string $nameEn, string $nameAr, string $type, ?int $parentId = null): array { - if (!accountingTableExists('acc_accounts')) { + ensureAccountingSchema(); + + if (!accountingTableExists('acc_accounts', true)) { return ['success' => false, 'error' => 'Accounting tables are not ready yet.']; } diff --git a/index.php b/index.php index 02a208c..25db608 100644 --- a/index.php +++ b/index.php @@ -3622,19 +3622,25 @@ if ($page === 'export') { $where = ["1=1"]; $params = []; + $referenceSearchColumn = db_column_exists($table, 'transaction_no') ? 'transaction_no' : null; if (!empty($_GET['search'])) { - $s = $_GET['search']; + $s = trim((string)$_GET['search']); $clean_id = preg_replace('/[^0-9]/', '', $s); - if ($clean_id !== '') { - $where[] = "(v.id LIKE ? OR c.name LIKE ? OR v.id = ?)"; - $params[] = "%$s%"; - $params[] = "%$s%"; - $params[] = $clean_id; - } else { - $where[] = "(v.id LIKE ? OR c.name LIKE ?)"; - $params[] = "%$s%"; - $params[] = "%$s%"; + $searchClauses = ["CAST(v.id AS CHAR) LIKE ?", "c.name LIKE ?"]; + $searchParams = ["%$s%", "%$s%"]; + + if ($referenceSearchColumn !== null) { + $searchClauses[] = "v.$referenceSearchColumn LIKE ?"; + $searchParams[] = "%$s%"; } + + if ($clean_id !== '') { + $searchClauses[] = "v.id = ?"; + $searchParams[] = $clean_id; + } + + $where[] = '(' . implode(' OR ', $searchClauses) . ')'; + $params = array_merge($params, $searchParams); } if (!empty($_GET['customer_id'])) { $where[] = "v.$cust_col = ?"; $params[] = $_GET['customer_id']; } if (!empty($_GET['start_date'])) { $where[] = "v.invoice_date >= ?"; $params[] = $_GET['start_date']; } @@ -4064,19 +4070,25 @@ switch ($page) { $where = ["1=1"]; $params = []; + $referenceSearchColumn = db_column_exists($table, 'transaction_no') ? 'transaction_no' : null; if (!empty($_GET['search'])) { - $s = $_GET['search']; + $s = trim((string)$_GET['search']); $clean_id = preg_replace('/[^0-9]/', '', $s); - if ($clean_id !== '') { - $where[] = "(v.id LIKE ? OR c.name LIKE ? OR v.id = ?)"; - $params[] = "%$s%"; - $params[] = "%$s%"; - $params[] = $clean_id; - } else { - $where[] = "(v.id LIKE ? OR c.name LIKE ?)"; - $params[] = "%$s%"; - $params[] = "%$s%"; + $searchClauses = ["CAST(v.id AS CHAR) LIKE ?", "c.name LIKE ?"]; + $searchParams = ["%$s%", "%$s%"]; + + if ($referenceSearchColumn !== null) { + $searchClauses[] = "v.$referenceSearchColumn LIKE ?"; + $searchParams[] = "%$s%"; } + + if ($clean_id !== '') { + $searchClauses[] = "v.id = ?"; + $searchParams[] = $clean_id; + } + + $where[] = '(' . implode(' OR ', $searchClauses) . ')'; + $params = array_merge($params, $searchParams); } if (!empty($_GET['customer_id'])) { @@ -4118,8 +4130,31 @@ switch ($page) { ORDER BY v.id DESC LIMIT $limit OFFSET $offset"); $stmt->execute($params); $data['invoices'] = $stmt->fetchAll(); + $documentPrefix = ($type === 'purchase') ? 'PUR' : 'INV'; foreach ($data['invoices'] as &$inv) { + $inv['due_date'] = $inv['due_date'] ?? null; + $transactionNo = trim((string)($inv['transaction_no'] ?? '')); + $partyFallback = ($type === 'sale' && !empty($inv['is_pos'])) ? 'Walk-in Customer' : '---'; + $normalizedPaymentType = strtolower(str_replace([' ', '-'], '_', (string)($inv['payment_type'] ?? 'cash'))); + $paymentTypeLabel = 'Cash'; + + if ($normalizedPaymentType === 'bank_transfer') { + $paymentTypeLabel = 'Bank Transfer'; + } elseif (in_array($normalizedPaymentType, ['card', 'credit_card'], true)) { + $paymentTypeLabel = 'Card'; + } elseif ($normalizedPaymentType === 'credit') { + $paymentTypeLabel = 'Credit'; + } + + $inv['party_name'] = trim((string)($inv['customer_name'] ?? '')) !== '' ? (string)$inv['customer_name'] : $partyFallback; + $inv['document_no'] = ($type === 'sale' && $transactionNo !== '') ? $transactionNo : $documentPrefix . '-' . str_pad((string)$inv['id'], 5, '0', STR_PAD_LEFT); + $inv['payment_type'] = $normalizedPaymentType; + $inv['payment_type_label'] = $paymentTypeLabel; + $inv['total_with_vat'] = (float)($inv['total_with_vat'] ?? (($inv['total_amount'] ?? 0) + ($inv['vat_amount'] ?? 0))); + $inv['paid_amount'] = (float)($inv['paid_amount'] ?? 0); + $inv['balance_amount'] = max($inv['total_with_vat'] - $inv['paid_amount'], 0); $inv['total_in_words'] = numberToWordsOMR($inv['total_with_vat']); + if ($type === 'sale') { $item_stmt = db()->prepare("SELECT ii.*, i.name_en, i.name_ar, i.vat_rate FROM invoice_items ii LEFT JOIN stock_items i ON ii.item_id = i.id WHERE ii.invoice_id = ?"); $item_stmt->execute([$inv['id']]); @@ -7960,13 +7995,14 @@ $projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Accounting System';
-
+ -
+ + -
+
-
+
-
+
-
- - -
-
- Limit - -
-
- +
- - + + @@ -8040,34 +8064,36 @@ $projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Accounting System'; foreach ($data['invoices'] as $inv): $total_all += (float)$inv['total_with_vat']; $total_paid += (float)$inv['paid_amount']; - $total_balance += ((float)$inv['total_with_vat'] - (float)$inv['paid_amount']); + $total_balance += (float)$inv['balance_amount']; $items = db()->prepare("SELECT ii.*, i.name_en, i.name_ar, i.vat_rate FROM $itemTable ii JOIN stock_items i ON ii.item_id = i.id WHERE ii.$fkCol = ?"); $items->execute([$inv['id']]); $inv['items'] = $items->fetchAll(PDO::FETCH_ASSOC); - $prefix = ($page === 'purchases') ? 'PUR' : 'INV'; + $isOverdue = !empty($inv['due_date']) && strtotime((string)$inv['due_date']) < time() && ($inv['status'] ?? '') !== 'paid'; ?> - - - + + - + - + + + + + + diff --git a/pages/accounting_logic.php b/pages/accounting_logic.php index 9304980..30586a7 100644 --- a/pages/accounting_logic.php +++ b/pages/accounting_logic.php @@ -1,4 +1,8 @@
Invoice #DateDue Date DatesPayment Status Total Paid
- - + +
+ +
+ +
+
+
+ - + - - --- - +
OMR OMR OMR OMR
@@ -8097,6 +8123,11 @@ $projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Accounting System';
No invoices found