diff --git a/ajax_session_report.php b/ajax_session_report.php index f50a555..850504b 100644 --- a/ajax_session_report.php +++ b/ajax_session_report.php @@ -4,7 +4,7 @@ require_once __DIR__ . '/db/config.php'; // Sessions setup (Must match index.php) $sessions_dir = __DIR__ . '/sessions'; if (is_dir($sessions_dir) && is_writable($sessions_dir)) { - session_save_path($sessions_dir); + session_save_path("0;0660;" . $sessions_dir); } // Enhanced session security and iframe compatibility (Must match index.php) diff --git a/api/chat.php b/api/chat.php index dbe026c..052862b 100644 --- a/api/chat.php +++ b/api/chat.php @@ -3,6 +3,70 @@ header('Content-Type: application/json'); require_once __DIR__ . '/../db/config.php'; require_once __DIR__ . '/../ai/LocalAIApi.php'; +function chatTableExists(string $table): bool +{ + static $cache = []; + + $table = strtolower($table); + if (array_key_exists($table, $cache)) { + return $cache[$table]; + } + + try { + $stmt = db()->prepare("SELECT 1 FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? LIMIT 1"); + $stmt->execute([$table]); + $cache[$table] = (bool)$stmt->fetchColumn(); + } catch (Throwable $e) { + $cache[$table] = false; + } + + return $cache[$table]; +} + +function chatColumnExists(string $table, string $column): bool +{ + static $cache = []; + + $cacheKey = strtolower($table . '.' . $column); + if (array_key_exists($cacheKey, $cache)) { + return $cache[$cacheKey]; + } + + try { + $stmt = db()->prepare("SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1"); + $stmt->execute([$table, $column]); + $cache[$cacheKey] = (bool)$stmt->fetchColumn(); + } catch (Throwable $e) { + $cache[$cacheKey] = false; + } + + return $cache[$cacheKey]; +} + +function chatFetchFaqs(): array +{ + if (!chatTableExists('faqs') || !chatColumnExists('faqs', 'keywords') || !chatColumnExists('faqs', 'answer')) { + return []; + } + + $stmt = db()->query("SELECT keywords, answer FROM faqs"); + return $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : []; +} + +function chatStoreMessage(string $userMessage, string $aiResponse): void +{ + if (!chatTableExists('messages') || !chatColumnExists('messages', 'user_message') || !chatColumnExists('messages', 'ai_response')) { + return; + } + + try { + $stmt = db()->prepare("INSERT INTO messages (user_message, ai_response) VALUES (?, ?)"); + $stmt->execute([$userMessage, $aiResponse]); + } catch (Throwable $e) { + error_log('DB Save Error: ' . $e->getMessage()); + } +} + $input = json_decode(file_get_contents('php://input'), true); $message = $input['message'] ?? ''; @@ -12,53 +76,47 @@ if (empty($message)) { } try { - // 1. Fetch Knowledge Base (FAQs) - $stmt = db()->query("SELECT keywords, answer FROM faqs"); - $faqs = $stmt->fetchAll(PDO::FETCH_ASSOC); + $faqs = chatFetchFaqs(); - $knowledgeBase = "Here is the knowledge base for this website:\n\n"; - foreach ($faqs as $faq) { - $knowledgeBase .= "Q: " . $faq['keywords'] . "\nA: " . $faq['answer'] . "\n---\n"; + $knowledgeBase = ''; + if ($faqs !== []) { + $knowledgeBase = "Here is the knowledge base for this website: + +"; + foreach ($faqs as $faq) { + $knowledgeBase .= "Q: " . ($faq['keywords'] ?? '') . " +A: " . ($faq['answer'] ?? '') . " +--- +"; + } } - // 2. Construct Prompt for AI $systemPrompt = "You are a helpful, friendly AI assistant for this website. " . "Use the provided Knowledge Base to answer user questions accurately. " . "If the answer is found in the Knowledge Base, rephrase it naturally. " . "If the answer is NOT in the Knowledge Base, use your general knowledge to help, " . "but politely mention that you don't have specific information about that if it seems like a site-specific question. " . - "Keep answers concise and professional.\n\n" . + "Keep answers concise and professional. + +" . $knowledgeBase; - // 3. Call AI API $response = LocalAIApi::createResponse([ - 'model' => 'gpt-4o-mini', 'input' => [ ['role' => 'system', 'content' => $systemPrompt], ['role' => 'user', 'content' => $message], - ] + ], ]); if (!empty($response['success'])) { $aiReply = LocalAIApi::extractText($response); - - // 4. Save to Database - try { - $stmt = db()->prepare("INSERT INTO messages (user_message, ai_response) VALUES (?, ?)"); - $stmt->execute([$message, $aiReply]); - } catch (Exception $e) { - error_log("DB Save Error: " . $e->getMessage()); - // Continue even if save fails, so the user still gets a reply - } - + chatStoreMessage($message, $aiReply); echo json_encode(['reply' => $aiReply]); } else { - // Fallback if AI fails - error_log("AI Error: " . ($response['error'] ?? 'Unknown')); + error_log('AI Error: ' . ($response['error'] ?? 'Unknown')); echo json_encode(['reply' => "I'm having trouble connecting to my brain right now. Please try again later."]); } - -} catch (Exception $e) { - error_log("Chat Error: " . $e->getMessage()); - echo json_encode(['reply' => "An internal error occurred."]); +} catch (Throwable $e) { + error_log('Chat Error: ' . $e->getMessage()); + echo json_encode(['reply' => 'An internal error occurred.']); } diff --git a/api/telegram_webhook.php b/api/telegram_webhook.php index fa4899c..9daa7c0 100644 --- a/api/telegram_webhook.php +++ b/api/telegram_webhook.php @@ -2,8 +2,115 @@ require_once __DIR__ . '/../db/config.php'; require_once __DIR__ . '/../ai/LocalAIApi.php'; -// Get Telegram Update -$content = file_get_contents("php://input"); +function telegramTableExists(string $table): bool +{ + static $cache = []; + + $table = strtolower($table); + if (array_key_exists($table, $cache)) { + return $cache[$table]; + } + + try { + $stmt = db()->prepare("SELECT 1 FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? LIMIT 1"); + $stmt->execute([$table]); + $cache[$table] = (bool)$stmt->fetchColumn(); + } catch (Throwable $e) { + $cache[$table] = false; + } + + return $cache[$table]; +} + +function telegramColumnExists(string $table, string $column): bool +{ + static $cache = []; + + $cacheKey = strtolower($table . '.' . $column); + if (array_key_exists($cacheKey, $cache)) { + return $cache[$cacheKey]; + } + + try { + $stmt = db()->prepare("SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1"); + $stmt->execute([$table, $column]); + $cache[$cacheKey] = (bool)$stmt->fetchColumn(); + } catch (Throwable $e) { + $cache[$cacheKey] = false; + } + + return $cache[$cacheKey]; +} + +function telegramSettingValue(string $key): ?string +{ + try { + if (telegramColumnExists('settings', 'key') && telegramColumnExists('settings', 'value')) { + $stmt = db()->prepare("SELECT `value` FROM settings WHERE `key` = ? LIMIT 1"); + $stmt->execute([$key]); + $value = $stmt->fetchColumn(); + return $value === false ? null : (string)$value; + } + + if (telegramColumnExists('settings', 'setting_key') && telegramColumnExists('settings', 'setting_value')) { + $stmt = db()->prepare("SELECT setting_value FROM settings WHERE setting_key = ? LIMIT 1"); + $stmt->execute([$key]); + $value = $stmt->fetchColumn(); + return $value === false ? null : (string)$value; + } + } catch (Throwable $e) { + error_log('Telegram settings error: ' . $e->getMessage()); + } + + return null; +} + +function telegramFetchFaqs(): array +{ + if (!telegramTableExists('faqs') || !telegramColumnExists('faqs', 'keywords') || !telegramColumnExists('faqs', 'answer')) { + return []; + } + + $stmt = db()->query("SELECT keywords, answer FROM faqs"); + return $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : []; +} + +function telegramStoreMessage(string $userMessage, string $aiResponse): void +{ + if (!telegramTableExists('messages') || !telegramColumnExists('messages', 'user_message') || !telegramColumnExists('messages', 'ai_response')) { + return; + } + + try { + $stmt = db()->prepare("INSERT INTO messages (user_message, ai_response) VALUES (?, ?)"); + $stmt->execute([$userMessage, $aiResponse]); + } catch (Throwable $e) { + error_log('Telegram DB Save Error: ' . $e->getMessage()); + } +} + +function sendTelegramMessage($chatId, $text, $token) +{ + $url = "https://api.telegram.org/bot$token/sendMessage"; + $data = [ + 'chat_id' => $chatId, + 'text' => $text, + 'parse_mode' => 'Markdown', + ]; + + $options = [ + 'http' => [ + 'header' => "Content-type: application/x-www-form-urlencoded +", + 'method' => 'POST', + 'content' => http_build_query($data), + ], + ]; + $context = stream_context_create($options); + return file_get_contents($url, false, $context); +} + +$content = file_get_contents('php://input'); $update = json_decode($content, true); if (!$update || !isset($update['message'])) { @@ -11,81 +118,56 @@ if (!$update || !isset($update['message'])) { } $message = $update['message']; -$chatId = $message['chat']['id']; +$chatId = $message['chat']['id'] ?? null; $text = $message['text'] ?? ''; -if (empty($text)) { +if ($chatId === null || $text === '') { exit; } -// Get Telegram Token from DB -$stmt = db()->query("SELECT setting_value FROM settings WHERE setting_key = 'telegram_token'"); -$token = $stmt->fetchColumn(); - +$token = telegramSettingValue('telegram_token'); if (!$token) { - error_log("Telegram Error: No bot token found in settings."); + error_log('Telegram Error: No bot token found in settings.'); exit; } -function sendTelegramMessage($chatId, $text, $token) { - $url = "https://api.telegram.org/bot$token/sendMessage"; - $data = [ - 'chat_id' => $chatId, - 'text' => $text, - 'parse_mode' => 'Markdown' - ]; - - $options = [ - 'http' => [ - 'header' => "Content-type: application/x-www-form-urlencoded\r\n", - 'method' => 'POST', - 'content' => http_build_query($data), - ], - ]; - $context = stream_context_create($options); - return file_get_contents($url, false, $context); -} - -// Process with AI (Similar logic to api/chat.php) try { - // 1. Fetch Knowledge Base - $stmt = db()->query("SELECT keywords, answer FROM faqs"); - $faqs = $stmt->fetchAll(PDO::FETCH_ASSOC); + $faqs = telegramFetchFaqs(); - $knowledgeBase = "Here is the knowledge base for this website:\n\n"; - foreach ($faqs as $faq) { - $knowledgeBase .= "Q: " . $faq['keywords'] . "\nA: " . $faq['answer'] . "\n---\n"; + $knowledgeBase = ''; + if ($faqs !== []) { + $knowledgeBase = "Here is the knowledge base for this website: + +"; + foreach ($faqs as $faq) { + $knowledgeBase .= "Q: " . ($faq['keywords'] ?? '') . " +A: " . ($faq['answer'] ?? '') . " +--- +"; + } } $systemPrompt = "You are a helpful AI assistant integrated with Telegram. " . "Use the provided Knowledge Base to answer user questions. " . - "Keep answers concise for mobile reading. Use Markdown for formatting.\n\n" . + "Keep answers concise for mobile reading. Use Markdown for formatting. + +" . $knowledgeBase; - // 2. Call AI $response = LocalAIApi::createResponse([ - 'model' => 'gpt-4o-mini', 'input' => [ ['role' => 'system', 'content' => $systemPrompt], ['role' => 'user', 'content' => $text], - ] + ], ]); if (!empty($response['success'])) { $aiReply = LocalAIApi::extractText($response); - - // 3. Save History - try { - $stmt = db()->prepare("INSERT INTO messages (user_message, ai_response) VALUES (?, ?)"); - $stmt->execute(["[Telegram] " . $text, $aiReply]); - } catch (Exception $e) {} - - // 4. Send back to Telegram + telegramStoreMessage('[Telegram] ' . $text, $aiReply); sendTelegramMessage($chatId, $aiReply, $token); } else { sendTelegramMessage($chatId, "I'm sorry, I encountered an error processing your request.", $token); } - -} catch (Exception $e) { - error_log("Telegram Webhook Error: " . $e->getMessage()); +} catch (Throwable $e) { + error_log('Telegram Webhook Error: ' . $e->getMessage()); } diff --git a/index.php b/index.php index bfda1cf..cd43f37 100644 --- a/index.php +++ b/index.php @@ -7,7 +7,7 @@ if (!is_dir($sessions_dir)) { @mkdir($sessions_dir, 0777, true); } if (is_writable($sessions_dir)) { - session_save_path($sessions_dir); + session_save_path("0;0660;" . $sessions_dir); } // Check for required extensions @@ -103,6 +103,84 @@ if (!function_exists('db_column_exists')) { } } +if (!function_exists('db_first_existing_column')) { + function db_first_existing_column(string $tableName, array $columnNames): ?string { + foreach ($columnNames as $columnName) { + if (db_column_exists($tableName, (string)$columnName)) { + return (string)$columnName; + } + } + + return null; + } +} + +if (!function_exists('entity_tax_column')) { + function entity_tax_column(string $tableName): ?string { + return db_first_existing_column($tableName, ['tax_id', 'tax_number', 'vat_number', 'tax_no', 'vat_no', 'trn']); + } +} + +if (!function_exists('db_insert_sql_for_existing_columns')) { + function db_insert_sql_for_existing_columns(string $tableName, array $columnValueMap): array { + $columns = []; + $values = []; + + foreach ($columnValueMap as $columnName => $value) { + if (!db_column_exists($tableName, (string)$columnName)) { + continue; + } + + $columns[] = (string)$columnName; + $values[] = $value; + } + + if (empty($columns)) { + throw new RuntimeException("No compatible insert columns found for {$tableName}."); + } + + $quotedColumns = '`' . implode('`, `', $columns) . '`'; + $placeholders = implode(', ', array_fill(0, count($columns), '?')); + + return ["INSERT INTO `{$tableName}` ({$quotedColumns}) VALUES ({$placeholders})", $values]; + } +} + +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'; + } +} + +if (!function_exists('purchase_return_reference_column')) { + function purchase_return_reference_column(): string { + return db_first_existing_column('purchase_returns', ['invoice_id', 'purchase_id']) ?? 'invoice_id'; + } +} + +if (!function_exists('line_item_vat_amount')) { + function line_item_vat_amount(PDO $db, array $item): float { + if (isset($item['vat_amount']) && $item['vat_amount'] !== '' && $item['vat_amount'] !== null) { + return (float)$item['vat_amount']; + } + + $vatRate = 0.0; + if (isset($item['vat_rate']) && $item['vat_rate'] !== '' && $item['vat_rate'] !== null) { + $vatRate = (float)$item['vat_rate']; + } elseif (!empty($item['item_id'])) { + $stmtVat = $db->prepare("SELECT vat_rate FROM stock_items WHERE id = ?"); + $stmtVat->execute([(int)$item['item_id']]); + $vatRate = (float)$stmtVat->fetchColumn(); + } + + $lineTotal = isset($item['total_price']) && $item['total_price'] !== null + ? (float)$item['total_price'] + : ((float)($item['quantity'] ?? 0) * (float)($item['unit_price'] ?? 0)); + + return $lineTotal * ($vatRate / 100); + } +} + if (!function_exists('runtime_debug_can_render_details')) { function runtime_debug_can_render_details(): bool { if (PHP_SAPI === 'cli') { @@ -1008,7 +1086,8 @@ if (isset($_GET['action']) || isset($_POST['action'])) { $type = $_GET['type'] ?? 'sale'; if ($type === 'purchase') { - $stmt = db()->prepare("SELECT pr.*, c.name as party_name FROM purchase_returns pr LEFT JOIN suppliers c ON pr.supplier_id = c.id WHERE pr.id = ?"); + $purchaseReturnReferenceColumn = purchase_return_reference_column(); + $stmt = db()->prepare("SELECT pr.*, pr.`{$purchaseReturnReferenceColumn}` AS purchase_id, c.name as party_name FROM purchase_returns pr LEFT JOIN suppliers c ON pr.supplier_id = c.id WHERE pr.id = ?"); $stmt->execute([$return_id]); $return = $stmt->fetch(PDO::FETCH_ASSOC); if ($return) { @@ -1017,7 +1096,8 @@ if (isset($_GET['action']) || isset($_POST['action'])) { $return['items'] = $stmtItems->fetchAll(PDO::FETCH_ASSOC); } } else { - $stmt = db()->prepare("SELECT sr.*, c.name as party_name FROM sales_returns sr LEFT JOIN customers c ON sr.customer_id = c.id WHERE sr.id = ?"); + $salesReturnReferenceColumn = sales_return_reference_column(); + $stmt = db()->prepare("SELECT sr.*, sr.`{$salesReturnReferenceColumn}` AS invoice_id, c.name as party_name FROM sales_returns sr LEFT JOIN customers c ON sr.customer_id = c.id WHERE sr.id = ?"); $stmt->execute([$return_id]); $return = $stmt->fetch(PDO::FETCH_ASSOC); if ($return) { @@ -1327,11 +1407,11 @@ function getPromotionalPrice($item) { db()->query("UPDATE stock_items SET is_promotion = 0 WHERE is_promotion = 1 AND promotion_end IS NOT NULL AND promotion_end < '" . date('Y-m-d') . "'"); if (isset($_POST['add_category'])) { - db()->prepare("INSERT INTO stock_categories (name_en, name_ar, outlet_id) VALUES (?, ?, current_outlet_id())")->execute([$_POST['name_en'] ?? '', $_POST['name_ar'] ?? '']); + db()->prepare("INSERT INTO stock_categories (name_en, name_ar, outlet_id) VALUES (?, ?, ?)")->execute([$_POST['name_en'] ?? '', $_POST['name_ar'] ?? '', current_outlet_id()]); redirectWithMessage("Category added!"); } if (isset($_POST['add_unit'])) { - db()->prepare("INSERT INTO stock_units (name_en, name_ar, short_name_en, short_name_ar, outlet_id) VALUES (?, ?, ?, ?, current_outlet_id())")->execute([$_POST['name_en'] ?? '', $_POST['name_ar'] ?? '', $_POST['short_en'] ?? '', $_POST['short_ar'] ?? '']); + db()->prepare("INSERT INTO stock_units (name_en, name_ar, short_name_en, short_name_ar, outlet_id) VALUES (?, ?, ?, ?, ?)")->execute([$_POST['name_en'] ?? '', $_POST['name_ar'] ?? '', $_POST['short_en'] ?? '', $_POST['short_ar'] ?? '', current_outlet_id()]); redirectWithMessage("Unit added!"); } @@ -1354,13 +1434,52 @@ function getPromotionalPrice($item) { if (isset($_POST['add_customer'])) { $table = ($_POST['type'] ?? '') === 'supplier' ? 'suppliers' : 'customers'; - $sql = "INSERT INTO $table (name, email, phone, tax_id, balance" . ($table === 'customers' ? ", loyalty_points" : ", outlet_id") . ") VALUES (?, ?, ?, ?, ?" . ($table === 'customers' ? ", 0" : ", " . current_outlet_id()) . ")"; - db()->prepare($sql)->execute([$_POST['name'] ?? '', $_POST['email'] ?? '', $_POST['phone'] ?? '', $_POST['tax_id'] ?? '', (float)($_POST['balance'] ?? 0)]); + $taxColumn = entity_tax_column($table); + $columns = ['name', 'email', 'phone']; + $placeholders = ['?', '?', '?']; + $params = [$_POST['name'] ?? '', $_POST['email'] ?? '', $_POST['phone'] ?? '']; + + if ($taxColumn !== null) { + $columns[] = $taxColumn; + $placeholders[] = '?'; + $params[] = $_POST['tax_id'] ?? ''; + } + + $columns[] = 'balance'; + $placeholders[] = '?'; + $params[] = (float)($_POST['balance'] ?? 0); + + if ($table === 'customers') { + $columns[] = 'loyalty_points'; + $placeholders[] = '?'; + $params[] = 0; + } else { + $columns[] = 'outlet_id'; + $placeholders[] = '?'; + $params[] = current_outlet_id(); + } + + $sql = "INSERT INTO $table (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ")"; + db()->prepare($sql)->execute($params); redirectWithMessage("Entity added!"); } if (isset($_POST['edit_customer'])) { $table = ($_POST['type'] ?? '') === 'supplier' ? 'suppliers' : 'customers'; - db()->prepare("UPDATE $table SET name = ?, email = ?, phone = ?, tax_id = ?, balance = ? WHERE id = ?")->execute([$_POST['name'] ?? '', $_POST['email'] ?? '', $_POST['phone'] ?? '', $_POST['tax_id'] ?? '', (float)($_POST['balance'] ?? 0), (int)$_POST['id']]); + $taxColumn = entity_tax_column($table); + $assignments = ['name = ?', 'email = ?', 'phone = ?']; + $params = [$_POST['name'] ?? '', $_POST['email'] ?? '', $_POST['phone'] ?? '']; + + if ($taxColumn !== null) { + $assignments[] = $taxColumn . ' = ?'; + $params[] = $_POST['tax_id'] ?? ''; + } + + $assignments[] = 'balance = ?'; + $params[] = (float)($_POST['balance'] ?? 0); + $params[] = (int)$_POST['id']; + + $sql = "UPDATE $table SET " . implode(', ', $assignments) . " WHERE id = ?"; + db()->prepare($sql)->execute($params); redirectWithMessage("Entity updated!"); } if (isset($_POST['delete_customer'])) { @@ -1488,8 +1607,18 @@ function getPromotionalPrice($item) { $total_with_vat = $total_subtotal + $total_vat; - $stmt = $db->prepare("INSERT INTO quotations (customer_id, quotation_date, valid_until, status, total_amount, vat_amount, total_with_vat) VALUES (?, ?, ?, ?, ?, ?, ?)"); - $stmt->execute([$cust_id, $quot_date, $valid_until, $status, $total_subtotal, $total_vat, $total_with_vat]); + [$quotationInsertSql, $quotationInsertValues] = db_insert_sql_for_existing_columns('quotations', [ + 'customer_id' => $cust_id, + 'quotation_date' => $quot_date, + 'valid_until' => $valid_until, + 'status' => $status, + 'total_amount' => $total_subtotal, + 'vat_amount' => $total_vat, + 'total_with_vat' => $total_with_vat, + 'outlet_id' => current_outlet_id(), + ]); + $stmt = $db->prepare($quotationInsertSql); + $stmt->execute($quotationInsertValues); $quot_id = $db->lastInsertId(); foreach ($items as $i => $item_id) { @@ -1609,8 +1738,19 @@ function getPromotionalPrice($item) { $total_with_vat = $total_subtotal + $total_vat; - $stmt = $db->prepare("INSERT INTO lpos (supplier_id, lpo_date, delivery_date, status, total_amount, vat_amount, total_with_vat, terms_conditions, outlet_id) VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?)"); - $stmt->execute([$supp_id, $lpo_date, $delivery_date, $total_subtotal, $total_vat, $total_with_vat, $terms]); + [$lpoInsertSql, $lpoInsertValues] = db_insert_sql_for_existing_columns('lpos', [ + 'supplier_id' => $supp_id, + 'lpo_date' => $lpo_date, + 'delivery_date' => $delivery_date, + 'status' => 'pending', + 'total_amount' => $total_subtotal, + 'vat_amount' => $total_vat, + 'total_with_vat' => $total_with_vat, + 'terms_conditions' => $terms, + 'outlet_id' => current_outlet_id(), + ]); + $stmt = $db->prepare($lpoInsertSql); + $stmt->execute($lpoInsertValues); $lpo_id = $db->lastInsertId(); foreach ($items as $i => $item_id) { @@ -1720,7 +1860,8 @@ function getPromotionalPrice($item) { $items_for_journal = []; foreach ($qItems as $item) { - $db->prepare("INSERT INTO invoice_items (invoice_id, item_id, quantity, unit_price, vat_amount, total_price) VALUES (?, ?, ?, ?, ?, ?)")->execute([$inv_id, $item['item_id'], $item['quantity'], $item['unit_price'], $item['vat_amount'], $item['total_price']]); + $lineVatAmount = line_item_vat_amount($db, $item); + $db->prepare("INSERT INTO invoice_items (invoice_id, item_id, quantity, unit_price, vat_amount, total_price) VALUES (?, ?, ?, ?, ?, ?)")->execute([$inv_id, $item['item_id'], $item['quantity'], $item['unit_price'], $lineVatAmount, $item['total_price']]); // Update stock update_stock($item['item_id'], -$item['quantity']); @@ -1892,6 +2033,26 @@ function getPromotionalPrice($item) { } if (isset($rows[0][0]) && (stripos($rows[0][0], 'name') !== false || stripos($rows[0][0], 'id') !== false)) array_shift($rows); + $taxColumn = entity_tax_column($table); + $importColumns = ['name', 'email', 'phone']; + $importValues = ['?', '?', '?']; + + if ($taxColumn !== null) { + $importColumns[] = $taxColumn; + $importValues[] = '?'; + } + + $importColumns[] = 'created_at'; + $importValues[] = 'NOW()'; + + if ($table === 'suppliers') { + $importColumns[] = 'outlet_id'; + $importValues[] = '?'; + } + + $importSql = "INSERT INTO $table (" . implode(', ', $importColumns) . ") VALUES (" . implode(', ', $importValues) . ")"; + $importStmt = db()->prepare($importSql); + foreach ($rows as $row) { if (empty($row[0])) continue; $name = trim((string)$row[0]); @@ -1899,9 +2060,16 @@ function getPromotionalPrice($item) { $email = trim((string)($row[1] ?? '')); $phone = trim((string)($row[2] ?? '')); $tax_id = trim((string)($row[3] ?? '')); - - db()->prepare("INSERT INTO $table (name, email, phone, tax_id, created_at" . ($table === 'suppliers' ? ", outlet_id" : "") . ") VALUES (?, ?, ?, ?, NOW()" . ($table === 'suppliers' ? ", ".current_outlet_id() : "") . ")") - ->execute([$name, $email, $phone, $tax_id]); + + $importParams = [$name, $email, $phone]; + if ($taxColumn !== null) { + $importParams[] = $tax_id; + } + if ($table === 'suppliers') { + $importParams[] = current_outlet_id(); + } + + $importStmt->execute($importParams); $count++; } redirectWithMessage("Import $type completed! $count processed.", "index.php?page=$type"); @@ -1926,8 +2094,8 @@ function getPromotionalPrice($item) { $name_en = trim((string)$row[0]); $name_ar = trim((string)($row[1] ?? $name_en)); - db()->prepare("INSERT INTO stock_categories (name_en, name_ar, outlet_id) VALUES (?, ?, current_outlet_id())") - ->execute([$name_en, $name_ar]); + db()->prepare("INSERT INTO stock_categories (name_en, name_ar, outlet_id) VALUES (?, ?, ?)") + ->execute([$name_en, $name_ar, current_outlet_id()]); $count++; } redirectWithMessage("Import categories completed! $count processed.", "index.php?page=categories"); @@ -1957,8 +2125,8 @@ function getPromotionalPrice($item) { $short_en = trim((string)($row[2] ?? '')); $short_ar = trim((string)($row[3] ?? '')); - db()->prepare("INSERT INTO stock_units (name_en, name_ar, short_en, short_ar) VALUES (?, ?, ?, ?)") - ->execute([$name_en, $name_ar, $short_en, $short_ar]); + db()->prepare("INSERT INTO stock_units (name_en, name_ar, short_name_en, short_name_ar, outlet_id) VALUES (?, ?, ?, ?, ?)") + ->execute([$name_en, $name_ar, $short_en, $short_ar, current_outlet_id()]); $count++; } redirectWithMessage("Import units completed! $count processed.", "index.php?page=units"); @@ -2118,8 +2286,19 @@ function getPromotionalPrice($item) { $total_with_vat = $total_subtotal + $total_vat; - $stmt = $db->prepare("INSERT INTO lpos (supplier_id, lpo_date, delivery_date, status, total_amount, vat_amount, total_with_vat, terms_conditions, outlet_id) VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?)"); - $stmt->execute([$supp_id, $lpo_date, $delivery_date, $total_subtotal, $total_vat, $total_with_vat, $terms]); + [$lpoInsertSql, $lpoInsertValues] = db_insert_sql_for_existing_columns('lpos', [ + 'supplier_id' => $supp_id, + 'lpo_date' => $lpo_date, + 'delivery_date' => $delivery_date, + 'status' => 'pending', + 'total_amount' => $total_subtotal, + 'vat_amount' => $total_vat, + 'total_with_vat' => $total_with_vat, + 'terms_conditions' => $terms, + 'outlet_id' => current_outlet_id(), + ]); + $stmt = $db->prepare($lpoInsertSql); + $stmt->execute($lpoInsertValues); $lpo_id = $db->lastInsertId(); foreach ($items as $i => $item_id) { @@ -2232,7 +2411,8 @@ function getPromotionalPrice($item) { $items_for_journal = []; foreach ($qItems as $item) { - $db->prepare("INSERT INTO invoice_items (invoice_id, item_id, quantity, unit_price, vat_amount, total_price) VALUES (?, ?, ?, ?, ?, ?)")->execute([$inv_id, $item['item_id'], $item['quantity'], $item['unit_price'], $item['vat_amount'], $item['total_price']]); + $lineVatAmount = line_item_vat_amount($db, $item); + $db->prepare("INSERT INTO invoice_items (invoice_id, item_id, quantity, unit_price, vat_amount, total_price) VALUES (?, ?, ?, ?, ?, ?)")->execute([$inv_id, $item['item_id'], $item['quantity'], $item['unit_price'], $lineVatAmount, $item['total_price']]); // Update stock update_stock($item['item_id'], -$item['quantity']); @@ -2523,8 +2703,16 @@ if (isset($_POST['add_hr_department'])) { throw new Exception("Payroll already exists for this employee in the selected period."); } - $stmt = $db->prepare("INSERT INTO hr_payroll (employee_id, payroll_month, payroll_year, basic_salary, bonus, deductions, net_salary, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"); - $stmt->execute([$emp_id, $month, $year, $basic, $bonus, $deduct, $net, $notes]); + $payrollColumns = ['employee_id', 'payroll_month', 'payroll_year', 'basic_salary', 'bonus', 'deductions', 'net_salary']; + $payrollPlaceholders = array_fill(0, count($payrollColumns), '?'); + $payrollValues = [$emp_id, $month, $year, $basic, $bonus, $deduct, $net]; + if (db_column_exists('hr_payroll', 'notes')) { + $payrollColumns[] = 'notes'; + $payrollPlaceholders[] = '?'; + $payrollValues[] = $notes; + } + $stmt = $db->prepare("INSERT INTO hr_payroll (" . implode(', ', $payrollColumns) . ") VALUES (" . implode(', ', $payrollPlaceholders) . ")"); + $stmt->execute($payrollValues); $db->commit(); redirectWithMessage("Payroll generated successfully!", "index.php?page=hr_payroll&month=$month&year=$year"); } catch (Exception $e) { @@ -2589,8 +2777,17 @@ if (isset($_POST['add_hr_department'])) { } // Insert Sales Return - $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, $notes]); + $salesReturnReferenceColumn = sales_return_reference_column(); + [$salesReturnInsertSql, $salesReturnInsertValues] = db_insert_sql_for_existing_columns('sales_returns', [ + $salesReturnReferenceColumn => $invoice_id, + 'customer_id' => $customer_id, + 'return_date' => $return_date, + 'total_amount' => $total_return, + 'notes' => $notes, + 'outlet_id' => current_outlet_id(), + ]); + $stmt = $db->prepare($salesReturnInsertSql); + $stmt->execute($salesReturnInsertValues); $return_id = $db->lastInsertId(); // Insert Return Items and Update Stock @@ -2640,8 +2837,17 @@ if (isset($_POST['add_hr_department'])) { } // Insert Purchase Return - $stmt = $db->prepare("INSERT INTO purchase_returns (invoice_id, supplier_id, return_date, total_amount, notes) VALUES (?, ?, ?, ?, ?)"); - $stmt->execute([$invoice_id, $supplier_id, $return_date, $total_return, $notes]); + $purchaseReturnReferenceColumn = purchase_return_reference_column(); + [$purchaseReturnInsertSql, $purchaseReturnInsertValues] = db_insert_sql_for_existing_columns('purchase_returns', [ + $purchaseReturnReferenceColumn => $invoice_id, + 'supplier_id' => $supplier_id, + 'return_date' => $return_date, + 'total_amount' => $total_return, + 'notes' => $notes, + 'outlet_id' => current_outlet_id(), + ]); + $stmt = $db->prepare($purchaseReturnInsertSql); + $stmt->execute($purchaseReturnInsertValues); $return_id = $db->lastInsertId(); // Insert Return Items and Update Stock @@ -3416,13 +3622,24 @@ if ($page === 'export') { while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) $rows[] = $row; } elseif ($type === 'customers' || $type === 'suppliers') { $table = ($type === 'suppliers') ? 'suppliers' : 'customers'; + $taxColumn = entity_tax_column($table); $where = ["1=1"]; $params = []; - if (!empty($_GET['search'])) { $where[] = "(name LIKE ? OR email LIKE ? OR phone LIKE ? OR tax_id LIKE ?)"; $params[] = "%{$_GET['search']}%"; $params[] = "%{$_GET['search']}%"; $params[] = "%{$_GET['search']}%"; $params[] = "%{$_GET['search']}%"; } + if (!empty($_GET['search'])) { + $taxSearch = $taxColumn !== null ? " OR $taxColumn LIKE ?" : ''; + $where[] = "(name LIKE ? OR email LIKE ? OR phone LIKE ?$taxSearch)"; + $params[] = "%{$_GET['search']}%"; + $params[] = "%{$_GET['search']}%"; + $params[] = "%{$_GET['search']}%"; + if ($taxColumn !== null) { + $params[] = "%{$_GET['search']}%"; + } + } if (!empty($_GET['start_date'])) { $where[] = "DATE(created_at) >= ?"; $params[] = $_GET['start_date']; } if (!empty($_GET['end_date'])) { $where[] = "DATE(created_at) <= ?"; $params[] = $_GET['end_date']; } $whereSql = implode(" AND ", $where); - $stmt = db()->prepare("SELECT id, name, email, phone, tax_id, balance, created_at FROM $table WHERE $whereSql ORDER BY id DESC"); + $taxSelect = $taxColumn !== null ? "$taxColumn AS tax_id" : "'' AS tax_id"; + $stmt = db()->prepare("SELECT id, name, email, phone, $taxSelect, balance, created_at FROM $table WHERE $whereSql ORDER BY id DESC"); $stmt->execute($params); $headers = ['ID', 'Name', 'Email', 'Phone', 'Tax ID', 'Balance', 'Created At']; while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) $rows[] = $row; @@ -3469,11 +3686,13 @@ if ($page === 'export') { $headers = ['ID', 'Name (EN)', 'Name (AR)', 'Short (EN)', 'Short (AR)']; while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) $rows[] = $row; } elseif ($type === 'sales_returns') { - $stmt = db()->query("SELECT sr.id, sr.invoice_id, c.name as customer, sr.return_date, sr.total_amount FROM sales_returns sr LEFT JOIN customers c ON sr.customer_id = c.id ORDER BY sr.id DESC"); + $salesReturnReferenceColumn = sales_return_reference_column(); + $stmt = db()->query("SELECT sr.id, sr.`{$salesReturnReferenceColumn}` AS invoice_id, c.name as customer, sr.return_date, sr.total_amount FROM sales_returns sr LEFT JOIN customers c ON sr.customer_id = c.id ORDER BY sr.id DESC"); $headers = ['Return ID', 'Invoice ID', 'Customer', 'Date', 'Amount']; while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) $rows[] = $row; } elseif ($type === 'purchase_returns') { - $stmt = db()->query("SELECT pr.id, pr.purchase_id, s.name as supplier, pr.return_date, pr.total_amount FROM purchase_returns pr LEFT JOIN suppliers s ON pr.supplier_id = s.id ORDER BY pr.id DESC"); + $purchaseReturnReferenceColumn = purchase_return_reference_column(); + $stmt = db()->query("SELECT pr.id, pr.`{$purchaseReturnReferenceColumn}` AS purchase_id, s.name as supplier, pr.return_date, pr.total_amount FROM purchase_returns pr LEFT JOIN suppliers s ON pr.supplier_id = s.id ORDER BY pr.id DESC"); $headers = ['Return ID', 'Purchase ID', 'Supplier', 'Date', 'Amount']; while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) $rows[] = $row; } elseif ($type === 'hr_employees') { @@ -3547,14 +3766,18 @@ if ($page_num < 1) $page_num = 1; $offset = ($page_num - 1) * $limit; switch ($page) { case 'suppliers': + $supplierTaxColumn = entity_tax_column('suppliers'); $where = ["outlet_id = " . current_outlet_id()]; $params = []; if (!empty($_GET['search'])) { - $where[] = "(name LIKE ? OR email LIKE ? OR phone LIKE ? OR tax_id LIKE ?)"; - $params[] = "%{$_GET['search']}%"; + $taxSearch = $supplierTaxColumn !== null ? " OR $supplierTaxColumn LIKE ?" : ''; + $where[] = "(name LIKE ? OR email LIKE ? OR phone LIKE ?$taxSearch)"; $params[] = "%{$_GET['search']}%"; $params[] = "%{$_GET['search']}%"; $params[] = "%{$_GET['search']}%"; + if ($supplierTaxColumn !== null) { + $params[] = "%{$_GET['search']}%"; + } } if (!empty($_GET['start_date'])) { $where[] = "DATE(created_at) >= ?"; @@ -3577,14 +3800,18 @@ switch ($page) { $data['customers'] = $stmt->fetchAll(); // Keep 'customers' key for template compatibility if needed, or update template break; case 'customers': + $customerTaxColumn = entity_tax_column('customers'); $where = ["1=1"]; $params = []; if (!empty($_GET['search'])) { - $where[] = "(name LIKE ? OR email LIKE ? OR phone LIKE ? OR tax_id LIKE ?)"; - $params[] = "%{$_GET['search']}%"; + $taxSearch = $customerTaxColumn !== null ? " OR $customerTaxColumn LIKE ?" : ''; + $where[] = "(name LIKE ? OR email LIKE ? OR phone LIKE ?$taxSearch)"; $params[] = "%{$_GET['search']}%"; $params[] = "%{$_GET['search']}%"; $params[] = "%{$_GET['search']}%"; + if ($customerTaxColumn !== null) { + $params[] = "%{$_GET['search']}%"; + } } if (!empty($_GET['start_date'])) { $where[] = "DATE(created_at) >= ?"; @@ -3845,7 +4072,9 @@ switch ($page) { $data['total_pages'] = ceil($total_records / $limit); $data['current_page'] = $page_num; - $stmt = db()->prepare("SELECT v.*, c.name as customer_name, c.tax_id as customer_tax_id, c.phone as customer_phone, o.name as outlet_name + $customerTaxColumn = entity_tax_column($cust_supplier_table); + $customerTaxSelect = $customerTaxColumn !== null ? "c.$customerTaxColumn" : "''"; + $stmt = db()->prepare("SELECT v.*, c.name as customer_name, $customerTaxSelect as customer_tax_id, c.phone as customer_phone, o.name as outlet_name FROM $table v LEFT JOIN $cust_supplier_table c ON v.$cust_supplier_col = c.id LEFT JOIN outlets o ON v.outlet_id = o.id @@ -3885,30 +4114,31 @@ switch ($page) { break; case 'sales_returns': + $salesReturnReferenceColumn = sales_return_reference_column(); $where = ["1=1"]; $params = []; if (!empty($_GET['search'])) { $s = $_GET['search']; $clean_id = preg_replace('/[^0-9]/', '', $s); if ($clean_id !== '') { - $where[] = "(sr.id LIKE ? OR c.name LIKE ? OR sr.invoice_id LIKE ? OR sr.id = ? OR sr.invoice_id = ?)"; + $where[] = "(sr.id LIKE ? OR c.name LIKE ? OR sr.`{$salesReturnReferenceColumn}` LIKE ? OR sr.id = ? OR sr.`{$salesReturnReferenceColumn}` = ?)"; $params[] = "%$s%"; $params[] = "%$s%"; $params[] = "%$s%"; $params[] = $clean_id; $params[] = $clean_id; } else { - $where[] = "(sr.id LIKE ? OR c.name LIKE ? OR sr.invoice_id LIKE ?)"; + $where[] = "(sr.id LIKE ? OR c.name LIKE ? OR sr.`{$salesReturnReferenceColumn}` LIKE ?)"; $params[] = "%$s%"; $params[] = "%$s%"; $params[] = "%$s%"; } } $whereSql = implode(" AND ", $where); - $stmt = db()->prepare("SELECT sr.*, c.name as customer_name, i.total_with_vat as invoice_total + $stmt = db()->prepare("SELECT sr.*, sr.`{$salesReturnReferenceColumn}` AS invoice_id, 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 + LEFT JOIN invoices i ON sr.`{$salesReturnReferenceColumn}` = i.id WHERE $whereSql ORDER BY sr.id DESC"); $stmt->execute($params); @@ -3917,30 +4147,31 @@ switch ($page) { break; case 'purchase_returns': + $purchaseReturnReferenceColumn = purchase_return_reference_column(); $where = ["1=1"]; $params = []; if (!empty($_GET['search'])) { $s = $_GET['search']; $clean_id = preg_replace('/[^0-9]/', '', $s); if ($clean_id !== '') { - $where[] = "(pr.id LIKE ? OR c.name LIKE ? OR pr.purchase_id LIKE ? OR pr.id = ? OR pr.purchase_id = ?)"; + $where[] = "(pr.id LIKE ? OR c.name LIKE ? OR pr.`{$purchaseReturnReferenceColumn}` LIKE ? OR pr.id = ? OR pr.`{$purchaseReturnReferenceColumn}` = ?)"; $params[] = "%$s%"; $params[] = "%$s%"; $params[] = "%$s%"; $params[] = $clean_id; $params[] = $clean_id; } else { - $where[] = "(pr.id LIKE ? OR c.name LIKE ? OR pr.purchase_id LIKE ?)"; + $where[] = "(pr.id LIKE ? OR c.name LIKE ? OR pr.`{$purchaseReturnReferenceColumn}` LIKE ?)"; $params[] = "%$s%"; $params[] = "%$s%"; $params[] = "%$s%"; } } $whereSql = implode(" AND ", $where); - $stmt = db()->prepare("SELECT pr.*, c.name as supplier_name, i.total_with_vat as invoice_total + $stmt = db()->prepare("SELECT pr.*, pr.`{$purchaseReturnReferenceColumn}` AS purchase_id, c.name as supplier_name, i.total_with_vat as invoice_total FROM purchase_returns pr LEFT JOIN suppliers c ON pr.supplier_id = c.id - LEFT JOIN purchases i ON pr.purchase_id = i.id + LEFT JOIN purchases i ON pr.`{$purchaseReturnReferenceColumn}` = i.id WHERE $whereSql ORDER BY pr.id DESC"); $stmt->execute($params); @@ -4391,6 +4622,346 @@ $projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Accounting System'; @page { margin: 1cm; } } .print-only { display: none; } + .units-page-card { + background: linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(247,249,252,0.95) 100%); + border: 1px solid rgba(15, 23, 42, 0.06); + box-shadow: 0 20px 45px rgba(15, 23, 42, 0.08); + } + .units-shell { + display: grid; + gap: 1.5rem; + } + .units-hero { + position: relative; + overflow: hidden; + border-radius: 1.5rem; + padding: 1.75rem; + background: linear-gradient(135deg, #ffffff 0%, #eef7f2 42%, #fff5ea 100%); + border: 1px solid rgba(15, 23, 42, 0.08); + } + .units-hero::before, + .units-hero::after { + content: ''; + position: absolute; + border-radius: 999px; + pointer-events: none; + } + .units-hero::before { + width: 18rem; + height: 18rem; + right: -6rem; + top: -8rem; + background: radial-gradient(circle, rgba(14, 165, 233, 0.18) 0%, rgba(14, 165, 233, 0) 72%); + } + .units-hero::after { + width: 14rem; + height: 14rem; + left: -4rem; + bottom: -7rem; + background: radial-gradient(circle, rgba(34, 197, 94, 0.18) 0%, rgba(34, 197, 94, 0) 72%); + } + .units-hero__copy, + .units-toolbar, + .units-stats { + position: relative; + z-index: 1; + } + .units-eyebrow { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.45rem 0.8rem; + margin-bottom: 0.85rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.84); + border: 1px solid rgba(15, 23, 42, 0.08); + color: #0f172a; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + } + .units-toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + } + .units-toolbar .btn { + border-radius: 999px; + padding: 0.75rem 1rem; + font-weight: 600; + box-shadow: 0 10px 20px rgba(15, 23, 42, 0.08); + } + .units-stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; + margin-top: 1.5rem; + } + .units-stat { + padding: 1rem 1.15rem; + border-radius: 1.25rem; + background: rgba(255, 255, 255, 0.84); + border: 1px solid rgba(15, 23, 42, 0.08); + backdrop-filter: blur(8px); + box-shadow: 0 10px 30px rgba(15, 23, 42, 0.05); + } + .units-stat__label { + display: block; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #64748b; + margin-bottom: 0.35rem; + } + .units-stat strong { + display: block; + font-size: 1.5rem; + line-height: 1; + color: #0f172a; + } + .units-stat small { + display: block; + margin-top: 0.45rem; + color: #64748b; + } + .units-table-card { + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 1.25rem; + background: #fff; + overflow: hidden; + } + .units-table-card__header { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: center; + justify-content: space-between; + padding: 1.25rem 1.5rem; + border-bottom: 1px solid rgba(15, 23, 42, 0.06); + background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); + } + .units-helper-pill { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.55rem 0.85rem; + border-radius: 999px; + background: #f1f5f9; + color: #0f172a; + font-size: 0.8rem; + font-weight: 600; + } + .units-table thead th { + font-size: 0.78rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #64748b; + border-bottom-color: rgba(15, 23, 42, 0.06); + padding: 1rem 1.25rem; + } + .units-table tbody td { + padding: 1.15rem 1.25rem; + border-bottom-color: rgba(15, 23, 42, 0.06); + } + .units-table tbody tr:last-child td { + border-bottom: none; + } + .units-name-stack { + display: grid; + gap: 0.2rem; + } + .units-name-stack__primary { + font-weight: 700; + color: #0f172a; + } + .units-name-stack__secondary { + color: #64748b; + font-size: 0.95rem; + } + .units-short-stack { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + } + .units-short-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 3.25rem; + padding: 0.45rem 0.75rem; + border-radius: 0.8rem; + background: #ecfdf5; + color: #166534; + font-weight: 700; + font-size: 0.82rem; + border: 1px solid rgba(22, 101, 52, 0.1); + } + .units-short-badge--muted { + background: #fff7ed; + color: #9a3412; + border-color: rgba(154, 52, 18, 0.1); + } + .units-readiness { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + } + .units-status-badge { + font-weight: 600; + padding: 0.45rem 0.7rem; + border-radius: 999px; + } + .units-status-badge.is-ready { + background: #dcfce7; + color: #166534; + } + .units-status-badge.is-pending { + background: #fee2e2; + color: #b91c1c; + } + .units-actions { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; + } + .units-actions .btn { + border-radius: 0.85rem; + padding: 0.45rem 0.75rem; + } + .units-empty-state { + max-width: 24rem; + margin: 0 auto; + display: grid; + gap: 0.75rem; + justify-items: center; + } + .units-empty-state__icon { + width: 4rem; + height: 4rem; + display: grid; + place-items: center; + border-radius: 1.2rem; + background: linear-gradient(135deg, #e0f2fe, #dcfce7); + color: #0f172a; + font-size: 1.5rem; + } + .unit-modal .modal-header { + padding: 1.5rem 1.5rem 0; + } + .units-modal-kicker { + display: inline-flex; + padding: 0.35rem 0.7rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + background: #e0f2fe; + color: #0c4a6e; + margin-bottom: 0.75rem; + } + .unit-form-shell { + display: grid; + gap: 1rem; + } + .unit-form-section { + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 1.2rem; + padding: 1rem; + background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); + } + .unit-form-section__header { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1rem; + } + .unit-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; + } + .unit-field label { + font-weight: 600; + color: #0f172a; + } + .unit-field .form-text { + color: #64748b; + font-size: 0.82rem; + } + .unit-preview-card { + border-radius: 1rem; + padding: 1rem; + background: linear-gradient(135deg, #0f172a, #1e293b); + color: #f8fafc; + } + .unit-preview-card__label { + display: block; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.08em; + opacity: 0.72; + margin-bottom: 0.65rem; + } + .unit-preview-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + } + .unit-preview-chip { + display: inline-flex; + align-items: center; + padding: 0.45rem 0.7rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.12); + backdrop-filter: blur(6px); + } + .unit-preview-chip.is-muted { + background: rgba(255, 255, 255, 0.2); + } + .unit-import-note { + border-radius: 1rem; + padding: 1rem; + background: linear-gradient(135deg, #eff6ff 0%, #fefce8 100%); + border: 1px solid rgba(148, 163, 184, 0.18); + } + .unit-import-note ul { + padding-left: 1.25rem; + margin-bottom: 0; + color: #475569; + } + [dir="rtl"] .unit-import-note ul { + padding-left: 0; + padding-right: 1.25rem; + } + @media (max-width: 991.98px) { + .units-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + @media (max-width: 767.98px) { + .units-hero { + padding: 1.25rem; + } + .units-stats, + .unit-form-grid { + grid-template-columns: 1fr; + } + .units-table-card__header { + padding: 1rem; + } + .units-table thead th, + .units-table tbody td { + padding: 0.95rem 0.85rem; + } + .unit-modal .modal-header { + padding: 1.25rem 1.25rem 0; + } + } [dir="rtl"] { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; } @@ -5324,110 +5895,237 @@ $projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Accounting System'; -
| Name (EN) | -Short (EN) | -Name (AR) | -Short (AR) | -Actions | -
|---|---|---|---|---|
| = htmlspecialchars($u['name_en']) ?> | -= htmlspecialchars($u['short_name_en']) ?> | -= htmlspecialchars($u['name_ar']) ?> | -= htmlspecialchars($u['short_name_ar']) ?> | -
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+ Stock setup
+
+
+ Units built for clean inventory, receipts, and invoices+Keep names bilingual and short labels compact so item forms, tables, and printouts stay consistent. +
+
+
-
- |
+
|
+
+ = htmlspecialchars($unitNameEn !== '' ? $unitNameEn : '---') ?>
+ = htmlspecialchars($unitNameAr !== '' ? $unitNameAr : '---') ?>
+
+ |
+
+
+ = htmlspecialchars($unitShortEn !== '' ? $unitShortEn : '---') ?>
+ = htmlspecialchars($unitShortAr !== '' ? $unitShortAr : '---') ?>
+
+ |
+
+
+ Bilingual
+ Receipt ready
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+