From 252f9561f30fb206d2345d509399816df7a45552 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Thu, 9 Apr 2026 10:12:19 +0000 Subject: [PATCH] Auto commit: 2026-04-09T10:12:19.615Z --- app.php | 763 ++++++++++++++++++++++++++++++++++++++++++ assets/css/custom.css | 688 ++++++++++++++++++------------------- assets/js/main.js | 141 ++++++-- customers.php | 136 ++++++++ healthz.php | 21 ++ index.php | 395 ++++++++++++++-------- order.php | 91 +++++ products.php | 338 +++++++++++++++++++ sales.php | 169 ++++++++++ 9 files changed, 2224 insertions(+), 518 deletions(-) create mode 100644 app.php create mode 100644 customers.php create mode 100644 healthz.php create mode 100644 order.php create mode 100644 products.php create mode 100644 sales.php diff --git a/app.php b/app.php new file mode 100644 index 0000000..1c20190 --- /dev/null +++ b/app.php @@ -0,0 +1,763 @@ +exec("CREATE TABLE IF NOT EXISTS customers ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(160) NOT NULL, + branch_name VARCHAR(160) NOT NULL, + phone VARCHAR(40) DEFAULT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'active', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); + + $pdo->exec("CREATE TABLE IF NOT EXISTS products ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + sku VARCHAR(80) NOT NULL UNIQUE, + name VARCHAR(160) NOT NULL, + category VARCHAR(20) NOT NULL, + unit VARCHAR(40) NOT NULL, + stock_qty DECIMAL(12,2) NOT NULL DEFAULT 0, + cost_price DECIMAL(12,2) NOT NULL DEFAULT 0, + sale_price DECIMAL(12,2) NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); + + $pdo->exec("CREATE TABLE IF NOT EXISTS customer_prices ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + customer_id INT UNSIGNED NOT NULL, + product_id INT UNSIGNED NOT NULL, + special_price DECIMAL(12,2) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uniq_customer_product (customer_id, product_id), + CONSTRAINT fk_customer_prices_customer FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE CASCADE, + CONSTRAINT fk_customer_prices_product FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); + + $pdo->exec("CREATE TABLE IF NOT EXISTS sales_orders ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + order_number VARCHAR(40) NOT NULL UNIQUE, + customer_id INT UNSIGNED NOT NULL, + status VARCHAR(30) NOT NULL DEFAULT 'confirmed', + subtotal DECIMAL(12,2) NOT NULL DEFAULT 0, + expected_profit DECIMAL(12,2) NOT NULL DEFAULT 0, + notes TEXT DEFAULT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_sales_orders_customer FOREIGN KEY (customer_id) REFERENCES customers(id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); + + $pdo->exec("CREATE TABLE IF NOT EXISTS sales_order_items ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + sales_order_id INT UNSIGNED NOT NULL, + product_id INT UNSIGNED NOT NULL, + qty DECIMAL(12,2) NOT NULL, + unit_price DECIMAL(12,2) NOT NULL, + line_total DECIMAL(12,2) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_sales_items_order FOREIGN KEY (sales_order_id) REFERENCES sales_orders(id) ON DELETE CASCADE, + CONSTRAINT fk_sales_items_product FOREIGN KEY (product_id) REFERENCES products(id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); + + $pdo->exec("CREATE TABLE IF NOT EXISTS inventory_movements ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + product_id INT UNSIGNED NOT NULL, + movement_type VARCHAR(30) NOT NULL, + qty_change DECIMAL(12,2) NOT NULL, + stock_before DECIMAL(12,2) NOT NULL DEFAULT 0, + stock_after DECIMAL(12,2) NOT NULL DEFAULT 0, + reference_code VARCHAR(80) DEFAULT NULL, + notes VARCHAR(255) DEFAULT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_inventory_product_created (product_id, created_at), + CONSTRAINT fk_inventory_movements_product FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); + + seed_demo_data($pdo); + $booted = true; +} + +function seed_demo_data(PDO $pdo): void +{ + $customerCount = (int) $pdo->query("SELECT COUNT(*) FROM customers")->fetchColumn(); + if ($customerCount === 0) { + $stmt = $pdo->prepare('INSERT INTO customers (name, branch_name, phone, status) VALUES (?, ?, ?, ?)'); + $rows = [ + ['شركة السهول التجارية', 'فرع الرياض', '+966500000101', 'active'], + ['مؤسسة الواجهة الذكية', 'فرع جدة', '+966500000202', 'active'], + ['شركة المدى للتوزيع', 'فرع الدمام', '+966500000303', 'active'], + ]; + foreach ($rows as $row) { + $stmt->execute($row); + } + } + + $productCount = (int) $pdo->query("SELECT COUNT(*) FROM products")->fetchColumn(); + if ($productCount === 0) { + $stmt = $pdo->prepare('INSERT INTO products (sku, name, category, unit, stock_qty, cost_price, sale_price) VALUES (?, ?, ?, ?, ?, ?, ?)'); + $rows = [ + ['FG-101', 'منظف أرضيات 5 لتر', 'finished', 'كرتون', 84, 18.00, 24.00], + ['FG-102', 'معقم يد 500 مل', 'finished', 'كرتون', 52, 12.50, 17.00], + ['FG-103', 'صابون سائل 1 لتر', 'finished', 'كرتون', 38, 10.00, 14.50], + ['RM-201', 'مادة فعالة A', 'raw', 'برميل', 120, 7.50, 0.00], + ['RM-202', 'عبوات فارغة 500 مل', 'raw', 'رتبة', 360, 1.20, 0.00], + ]; + foreach ($rows as $row) { + $stmt->execute($row); + } + } + + $priceCount = (int) $pdo->query("SELECT COUNT(*) FROM customer_prices")->fetchColumn(); + if ($priceCount === 0) { + $customerIds = $pdo->query('SELECT id, name FROM customers ORDER BY id')->fetchAll(); + $productIds = $pdo->query("SELECT id, sku FROM products WHERE category = 'finished' ORDER BY id")->fetchAll(); + $customerMap = []; + foreach ($customerIds as $row) { + $customerMap[$row['name']] = (int) $row['id']; + } + $productMap = []; + foreach ($productIds as $row) { + $productMap[$row['sku']] = (int) $row['id']; + } + + $stmt = $pdo->prepare('INSERT INTO customer_prices (customer_id, product_id, special_price) VALUES (?, ?, ?)'); + $rows = [ + [$customerMap['شركة السهول التجارية'], $productMap['FG-101'], 22.50], + [$customerMap['شركة السهول التجارية'], $productMap['FG-102'], 15.90], + [$customerMap['مؤسسة الواجهة الذكية'], $productMap['FG-102'], 16.10], + [$customerMap['مؤسسة الواجهة الذكية'], $productMap['FG-103'], 13.60], + [$customerMap['شركة المدى للتوزيع'], $productMap['FG-101'], 23.20], + [$customerMap['شركة المدى للتوزيع'], $productMap['FG-103'], 13.80], + ]; + foreach ($rows as $row) { + $stmt->execute($row); + } + } +} + +function csrf_token(): string +{ + if (empty($_SESSION['csrf_token'])) { + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + } + return $_SESSION['csrf_token']; +} + +function verify_csrf(?string $token): bool +{ + return is_string($token) && isset($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token); +} + +function flash(string $type, string $message): void +{ + $_SESSION['flash'] = ['type' => $type, 'message' => $message]; +} + +function consume_flash(): ?array +{ + if (!isset($_SESSION['flash'])) { + return null; + } + $flash = $_SESSION['flash']; + unset($_SESSION['flash']); + return $flash; +} + +function project_name(): string +{ + return trim((string) ($_SERVER['PROJECT_NAME'] ?? 'NexusERP')) ?: 'NexusERP'; +} + +function render_header(string $pageTitle, string $active = 'dashboard'): void +{ + $projectDescription = (string) ($_SERVER['PROJECT_DESCRIPTION'] ?? 'ERP dashboard and sales operations workspace'); + $projectImageUrl = (string) ($_SERVER['PROJECT_IMAGE_URL'] ?? ''); + $fullTitle = $pageTitle . ' | ' . project_name(); + $nav = [ + 'dashboard' => ['href' => 'index.php', 'label' => 'لوحة التحكم'], + 'customers' => ['href' => 'customers.php', 'label' => 'العملاء'], + 'products' => ['href' => 'products.php', 'label' => 'المخزون'], + 'sales' => ['href' => 'sales.php', 'label' => 'أوامر البيع'], + ]; + $cssVersion = file_exists(__DIR__ . '/assets/css/custom.css') ? (string) filemtime(__DIR__ . '/assets/css/custom.css') : (string) time(); + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo '' . h($fullTitle) . ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + if ($projectImageUrl !== '') { + echo ''; + echo ''; + } + echo ''; + echo ''; + echo ''; + echo ''; + echo '
'; + echo '
'; + echo '
'; + echo '
'; + echo '
'; + echo '
NE
'; + echo '
'; + echo '
Single-company ERP
'; + echo '
' . h(project_name()) . '
'; + echo '
'; + echo '
'; + echo '
'; + echo 'Admin Demo'; + echo 'Health'; + echo '
'; + echo '
'; + echo ''; + echo '
'; + echo '
'; + echo '
'; + echo '
'; +} + +function render_footer(): void +{ + $flash = consume_flash(); + $jsVersion = file_exists(__DIR__ . '/assets/js/main.js') ? (string) filemtime(__DIR__ . '/assets/js/main.js') : (string) time(); + if ($flash) { + echo '
'; + echo '
'; + } + echo '
'; + echo '
'; + echo '
'; + echo '
'; + echo 'شريحة MVP أولية لعمليات البيع والمخزون.'; + echo 'جاهز للتوسعة إلى المشتريات، التصنيع، والمحاسبة.'; + echo '
'; + echo '
'; + echo '
'; + echo ''; + echo ''; + echo ''; +} + +function fetch_dashboard_metrics(): array +{ + $pdo = db(); + return [ + 'customers' => (int) $pdo->query('SELECT COUNT(*) FROM customers')->fetchColumn(), + 'products' => (int) $pdo->query('SELECT COUNT(*) FROM products')->fetchColumn(), + 'finished_stock' => (float) $pdo->query("SELECT COALESCE(SUM(stock_qty),0) FROM products WHERE category = 'finished'")->fetchColumn(), + 'raw_stock' => (float) $pdo->query("SELECT COALESCE(SUM(stock_qty),0) FROM products WHERE category = 'raw'")->fetchColumn(), + 'low_stock_products' => (int) $pdo->query("SELECT COUNT(*) FROM products WHERE stock_qty <= 40")->fetchColumn(), + 'orders_today' => (int) $pdo->query('SELECT COUNT(*) FROM sales_orders WHERE DATE(created_at) = CURDATE()')->fetchColumn(), + 'sales_today' => (float) $pdo->query('SELECT COALESCE(SUM(subtotal),0) FROM sales_orders WHERE DATE(created_at) = CURDATE()')->fetchColumn(), + 'profit_today' => (float) $pdo->query('SELECT COALESCE(SUM(expected_profit),0) FROM sales_orders WHERE DATE(created_at) = CURDATE()')->fetchColumn(), + ]; +} + +function fetch_sales_trend(): array +{ + $pdo = db(); + $stmt = $pdo->query("SELECT DATE(created_at) AS order_date, COUNT(*) AS order_count, COALESCE(SUM(subtotal),0) AS revenue + FROM sales_orders + WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 6 DAY) + GROUP BY DATE(created_at) + ORDER BY order_date ASC"); + $rows = $stmt->fetchAll(); + $indexed = []; + foreach ($rows as $row) { + $indexed[$row['order_date']] = $row; + } + $trend = []; + for ($i = 6; $i >= 0; $i--) { + $date = date('Y-m-d', strtotime('-' . $i . ' days')); + $trend[] = [ + 'date' => $date, + 'label' => date('d M', strtotime($date)), + 'order_count' => isset($indexed[$date]) ? (int) $indexed[$date]['order_count'] : 0, + 'revenue' => isset($indexed[$date]) ? (float) $indexed[$date]['revenue'] : 0.0, + ]; + } + return $trend; +} + +function fetch_recent_orders(int $limit = 8): array +{ + $pdo = db(); + $stmt = $pdo->prepare("SELECT so.id, so.order_number, so.status, so.subtotal, so.expected_profit, so.created_at, + c.name AS customer_name, c.branch_name + FROM sales_orders so + INNER JOIN customers c ON c.id = so.customer_id + ORDER BY so.created_at DESC, so.id DESC + LIMIT :limit"); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + return $stmt->fetchAll(); +} + +function fetch_products(): array +{ + $pdo = db(); + return $pdo->query('SELECT * FROM products ORDER BY category ASC, name ASC')->fetchAll(); +} + +function fetch_product_by_id(int $productId): ?array +{ + $pdo = db(); + $stmt = $pdo->prepare('SELECT * FROM products WHERE id = ? LIMIT 1'); + $stmt->execute([$productId]); + $product = $stmt->fetch(); + return $product ?: null; +} + +function fetch_inventory_movements(int $limit = 12): array +{ + $pdo = db(); + $stmt = $pdo->prepare("SELECT im.*, p.sku, p.name AS product_name, p.unit + FROM inventory_movements im + INNER JOIN products p ON p.id = im.product_id + ORDER BY im.created_at DESC, im.id DESC + LIMIT :limit"); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + return $stmt->fetchAll(); +} + +function fetch_product_options(): array +{ + $pdo = db(); + $stmt = $pdo->query('SELECT id, sku, name, category, unit, stock_qty FROM products ORDER BY name ASC'); + return $stmt->fetchAll(); +} + +function fetch_customers_with_catalog(): array +{ + $pdo = db(); + $customers = $pdo->query('SELECT * FROM customers ORDER BY created_at DESC, id DESC')->fetchAll(); + $stmt = $pdo->query("SELECT cp.customer_id, p.sku, p.name, p.unit, cp.special_price + FROM customer_prices cp + INNER JOIN products p ON p.id = cp.product_id + ORDER BY p.name ASC"); + $catalogRows = $stmt->fetchAll(); + $catalog = []; + foreach ($catalogRows as $row) { + $catalog[(int) $row['customer_id']][] = $row; + } + foreach ($customers as &$customer) { + $customer['catalog'] = $catalog[(int) $customer['id']] ?? []; + } + unset($customer); + return $customers; +} + + +function fetch_finished_products_for_pricing(): array +{ + $pdo = db(); + $stmt = $pdo->query("SELECT id, sku, name, unit, stock_qty, sale_price + FROM products + WHERE category = 'finished' + ORDER BY name ASC"); + return $stmt->fetchAll(); +} + +function normalize_product_category(string $category): ?string +{ + $category = trim($category); + return in_array($category, ['finished', 'raw'], true) ? $category : null; +} + +function create_product(array $input): array +{ + $sku = strtoupper(trim((string) ($input['sku'] ?? ''))); + $name = trim((string) ($input['name'] ?? '')); + $category = normalize_product_category((string) ($input['category'] ?? '')); + $unit = trim((string) ($input['unit'] ?? '')); + $stockQty = round((float) ($input['stock_qty'] ?? 0), 2); + $costPrice = round((float) ($input['cost_price'] ?? 0), 2); + $salePrice = round((float) ($input['sale_price'] ?? 0), 2); + + if ($sku === '' || $name === '' || $unit === '' || $category === null) { + return ['success' => false, 'message' => 'SKU واسم الصنف والفئة ووحدة القياس مطلوبة.']; + } + if ($stockQty < 0 || $costPrice < 0 || $salePrice < 0) { + return ['success' => false, 'message' => 'قيم المخزون والأسعار يجب أن تكون صفرًا أو أكبر.']; + } + + $pdo = db(); + try { + $pdo->beginTransaction(); + + $stmt = $pdo->prepare('INSERT INTO products (sku, name, category, unit, stock_qty, cost_price, sale_price) VALUES (?, ?, ?, ?, ?, ?, ?)'); + $stmt->execute([$sku, $name, $category, $unit, $stockQty, $costPrice, $salePrice]); + $productId = (int) $pdo->lastInsertId(); + + if ($stockQty > 0) { + record_inventory_movement($pdo, $productId, 'opening', $stockQty, 0.0, $stockQty, $sku, 'رصيد افتتاحي عند إنشاء الصنف'); + } + + $pdo->commit(); + return ['success' => true, 'message' => 'تم إنشاء الصنف بنجاح.', 'product_id' => $productId]; + } catch (PDOException $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + if ((string) $e->getCode() === '23000') { + return ['success' => false, 'message' => 'SKU مستخدم من قبل. اختر رمزًا مختلفًا.']; + } + return ['success' => false, 'message' => 'تعذر إنشاء الصنف حالياً.']; + } catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + return ['success' => false, 'message' => 'تعذر إنشاء الصنف حالياً.']; + } +} + +function update_product(int $productId, array $input): array +{ + $product = fetch_product_by_id($productId); + if (!$product) { + return ['success' => false, 'message' => 'الصنف المطلوب غير موجود.']; + } + + $sku = strtoupper(trim((string) ($input['sku'] ?? ''))); + $name = trim((string) ($input['name'] ?? '')); + $category = normalize_product_category((string) ($input['category'] ?? '')); + $unit = trim((string) ($input['unit'] ?? '')); + $costPrice = round((float) ($input['cost_price'] ?? 0), 2); + $salePrice = round((float) ($input['sale_price'] ?? 0), 2); + + if ($sku === '' || $name === '' || $unit === '' || $category === null) { + return ['success' => false, 'message' => 'SKU واسم الصنف والفئة ووحدة القياس مطلوبة.']; + } + if ($costPrice < 0 || $salePrice < 0) { + return ['success' => false, 'message' => 'الأسعار يجب أن تكون صفرًا أو أكبر.']; + } + + $pdo = db(); + try { + $stmt = $pdo->prepare('UPDATE products SET sku = ?, name = ?, category = ?, unit = ?, cost_price = ?, sale_price = ? WHERE id = ?'); + $stmt->execute([$sku, $name, $category, $unit, $costPrice, $salePrice, $productId]); + return ['success' => true, 'message' => 'تم تحديث بيانات الصنف بنجاح.']; + } catch (PDOException $e) { + if ((string) $e->getCode() === '23000') { + return ['success' => false, 'message' => 'SKU مستخدم من قبل. اختر رمزًا مختلفًا.']; + } + return ['success' => false, 'message' => 'تعذر تحديث الصنف حالياً.']; + } +} + +function record_inventory_movement(PDO $pdo, int $productId, string $movementType, float $qtyChange, float $stockBefore, float $stockAfter, ?string $referenceCode = null, ?string $notes = null): void +{ + $stmt = $pdo->prepare('INSERT INTO inventory_movements (product_id, movement_type, qty_change, stock_before, stock_after, reference_code, notes) VALUES (?, ?, ?, ?, ?, ?, ?)'); + $stmt->execute([$productId, $movementType, round($qtyChange, 2), round($stockBefore, 2), round($stockAfter, 2), $referenceCode, $notes]); +} + +function adjust_product_stock(int $productId, string $direction, float $qty, string $reason, string $notes): array +{ + $direction = trim($direction); + $reason = trim($reason); + $notes = trim($notes); + if (!in_array($direction, ['add', 'subtract'], true) || $qty <= 0) { + return ['success' => false, 'message' => 'أدخل عملية ضبط صحيحة وكمية أكبر من صفر.']; + } + + $product = fetch_product_by_id($productId); + if (!$product) { + return ['success' => false, 'message' => 'الصنف المطلوب غير موجود.']; + } + + $pdo = db(); + try { + $pdo->beginTransaction(); + + $lockStmt = $pdo->prepare('SELECT id, sku, stock_qty FROM products WHERE id = ? FOR UPDATE'); + $lockStmt->execute([$productId]); + $locked = $lockStmt->fetch(); + if (!$locked) { + throw new RuntimeException('Product not found during lock.'); + } + + $stockBefore = (float) $locked['stock_qty']; + $delta = $direction === 'add' ? $qty : -$qty; + $stockAfter = round($stockBefore + $delta, 2); + if ($stockAfter < 0) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + return ['success' => false, 'message' => 'لا يمكن خصم كمية أكبر من المخزون الحالي.']; + } + + $updateStmt = $pdo->prepare('UPDATE products SET stock_qty = ? WHERE id = ?'); + $updateStmt->execute([$stockAfter, $productId]); + + $movementType = $direction === 'add' ? 'adjustment_in' : 'adjustment_out'; + $movementNotes = $reason !== '' ? $reason : 'ضبط يدوي'; + if ($notes !== '') { + $movementNotes .= ' — ' . $notes; + } + record_inventory_movement($pdo, $productId, $movementType, $delta, $stockBefore, $stockAfter, $locked['sku'], $movementNotes); + + $pdo->commit(); + return ['success' => true, 'message' => 'تم تحديث المخزون للصنف بنجاح.']; + } catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + return ['success' => false, 'message' => 'تعذر ضبط المخزون حالياً.']; + } +} + +function create_customer_with_prices(string $name, string $branchName, string $phone, array $selectedProducts, array $priceInputs): array +{ + $name = trim($name); + $branchName = trim($branchName); + $phone = trim($phone); + + if ($name === '' || $branchName === '') { + return ['success' => false, 'message' => 'اسم العميل واسم الفرع مطلوبان.']; + } + + $productIds = []; + foreach ($selectedProducts as $productId) { + $id = (int) $productId; + if ($id > 0) { + $productIds[$id] = $id; + } + } + + if (!$productIds) { + return ['success' => false, 'message' => 'اختر صنفاً واحداً على الأقل وحدد له سعراً خاصاً.']; + } + + $pricingRows = []; + foreach ($productIds as $productId) { + $rawPrice = isset($priceInputs[$productId]) ? trim((string) $priceInputs[$productId]) : ''; + if ($rawPrice === '' || !is_numeric($rawPrice)) { + return ['success' => false, 'message' => 'أدخل سعراً صحيحاً لكل صنف تم اختياره.']; + } + $price = round((float) $rawPrice, 2); + if ($price <= 0) { + return ['success' => false, 'message' => 'السعر الخاص يجب أن يكون أكبر من صفر.']; + } + $pricingRows[] = ['product_id' => $productId, 'special_price' => $price]; + } + + $pdo = db(); + try { + $pdo->beginTransaction(); + + $customerStmt = $pdo->prepare('INSERT INTO customers (name, branch_name, phone, status) VALUES (?, ?, ?, ?)'); + $customerStmt->execute([$name, $branchName, $phone !== '' ? $phone : null, 'active']); + $customerId = (int) $pdo->lastInsertId(); + + $priceStmt = $pdo->prepare('INSERT INTO customer_prices (customer_id, product_id, special_price) VALUES (?, ?, ?)'); + foreach ($pricingRows as $row) { + $priceStmt->execute([$customerId, $row['product_id'], $row['special_price']]); + } + + $pdo->commit(); + return [ + 'success' => true, + 'message' => 'تمت إضافة العميل وربط الأسعار الخاصة به بنجاح.', + 'customer_id' => $customerId, + ]; + } catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + return ['success' => false, 'message' => 'تعذر حفظ العميل حالياً.']; + } +} + +function fetch_sales_form_catalog(): array +{ + $pdo = db(); + $stmt = $pdo->query("SELECT c.id AS customer_id, c.name AS customer_name, c.branch_name, + p.id AS product_id, p.sku, p.name AS product_name, p.unit, p.stock_qty, p.cost_price, p.sale_price, + cp.special_price + FROM customer_prices cp + INNER JOIN customers c ON c.id = cp.customer_id + INNER JOIN products p ON p.id = cp.product_id + ORDER BY c.name ASC, p.name ASC"); + $catalog = []; + foreach ($stmt->fetchAll() as $row) { + $customerId = (int) $row['customer_id']; + if (!isset($catalog[$customerId])) { + $catalog[$customerId] = [ + 'customer_id' => $customerId, + 'customer_name' => $row['customer_name'], + 'branch_name' => $row['branch_name'], + 'items' => [], + ]; + } + $catalog[$customerId]['items'][] = [ + 'product_id' => (int) $row['product_id'], + 'sku' => $row['sku'], + 'product_name' => $row['product_name'], + 'unit' => $row['unit'], + 'stock_qty' => (float) $row['stock_qty'], + 'cost_price' => (float) $row['cost_price'], + 'sale_price' => (float) $row['sale_price'], + 'special_price' => (float) $row['special_price'], + ]; + } + return $catalog; +} + +function format_money(float $value): string +{ + return number_format($value, 2) . ' ر.س'; +} + +function format_qty(float $value): string +{ + return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.'); +} + +function next_order_number(PDO $pdo): string +{ + $count = (int) $pdo->query('SELECT COUNT(*) FROM sales_orders WHERE DATE(created_at) = CURDATE()')->fetchColumn(); + return 'SO-' . date('Ymd') . '-' . str_pad((string) ($count + 1), 3, '0', STR_PAD_LEFT); +} + +function create_sales_order(int $customerId, int $productId, float $qty, string $notes): array +{ + $pdo = db(); + if ($qty <= 0) { + return ['success' => false, 'message' => 'الكمية يجب أن تكون أكبر من صفر.']; + } + + $stmt = $pdo->prepare("SELECT c.id AS customer_id, c.name AS customer_name, c.branch_name, + p.id AS product_id, p.name AS product_name, p.sku, p.unit, p.stock_qty, p.cost_price, + cp.special_price + FROM customer_prices cp + INNER JOIN customers c ON c.id = cp.customer_id + INNER JOIN products p ON p.id = cp.product_id + WHERE c.id = :customer_id AND p.id = :product_id + LIMIT 1"); + $stmt->execute([ + ':customer_id' => $customerId, + ':product_id' => $productId, + ]); + $row = $stmt->fetch(); + if (!$row) { + return ['success' => false, 'message' => 'هذا الصنف غير متاح لهذا العميل أو لا يوجد سعر خاص له.']; + } + + $unitPrice = (float) $row['special_price']; + $lineTotal = round($qty * $unitPrice, 2); + $expectedProfit = round($lineTotal - ($qty * (float) $row['cost_price']), 2); + $orderNumber = next_order_number($pdo); + + try { + $pdo->beginTransaction(); + + $stockLock = $pdo->prepare('SELECT stock_qty FROM products WHERE id = ? FOR UPDATE'); + $stockLock->execute([$productId]); + $stockRow = $stockLock->fetch(); + if (!$stockRow) { + throw new RuntimeException('الصنف غير موجود أثناء الحفظ.'); + } + + $stockBefore = (float) $stockRow['stock_qty']; + if ($qty > $stockBefore) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + return ['success' => false, 'message' => 'الكمية المطلوبة أكبر من المخزون المتاح حالياً.']; + } + $stockAfter = round($stockBefore - $qty, 2); + + $orderStmt = $pdo->prepare('INSERT INTO sales_orders (order_number, customer_id, status, subtotal, expected_profit, notes) VALUES (?, ?, ?, ?, ?, ?)'); + $orderStmt->execute([$orderNumber, $customerId, 'confirmed', $lineTotal, $expectedProfit, $notes !== '' ? $notes : null]); + $orderId = (int) $pdo->lastInsertId(); + + $itemStmt = $pdo->prepare('INSERT INTO sales_order_items (sales_order_id, product_id, qty, unit_price, line_total) VALUES (?, ?, ?, ?, ?)'); + $itemStmt->execute([$orderId, $productId, $qty, $unitPrice, $lineTotal]); + + $stockStmt = $pdo->prepare('UPDATE products SET stock_qty = ? WHERE id = ?'); + $stockStmt->execute([$stockAfter, $productId]); + + record_inventory_movement( + $pdo, + $productId, + 'sale', + -$qty, + $stockBefore, + $stockAfter, + $orderNumber, + 'خصم تلقائي من أمر البيع ' . $orderNumber + ); + + $pdo->commit(); + return [ + 'success' => true, + 'message' => 'تم إنشاء أمر البيع ' . $orderNumber . ' وتحديث المخزون تلقائياً.', + 'order_id' => $orderId, + 'order_number' => $orderNumber, + ]; + } catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + return ['success' => false, 'message' => 'حدث خطأ أثناء حفظ أمر البيع.']; + } +} + +function fetch_order_detail(int $orderId): ?array +{ + $pdo = db(); + $stmt = $pdo->prepare("SELECT so.*, c.name AS customer_name, c.branch_name, c.phone + FROM sales_orders so + INNER JOIN customers c ON c.id = so.customer_id + WHERE so.id = :id + LIMIT 1"); + $stmt->execute([':id' => $orderId]); + $order = $stmt->fetch(); + if (!$order) { + return null; + } + + $itemStmt = $pdo->prepare("SELECT soi.qty, soi.unit_price, soi.line_total, + p.name AS product_name, p.sku, p.unit, p.cost_price + FROM sales_order_items soi + INNER JOIN products p ON p.id = soi.product_id + WHERE soi.sales_order_id = :id"); + $itemStmt->execute([':id' => $orderId]); + $items = $itemStmt->fetchAll(); + + return ['order' => $order, 'items' => $items]; +} diff --git a/assets/css/custom.css b/assets/css/custom.css index 789132e..00176e7 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -1,403 +1,407 @@ +:root { + --bg: #f3f4f6; + --surface: #ffffff; + --surface-muted: #f8fafc; + --border: #e5e7eb; + --text: #0f172a; + --muted: #64748b; + --primary: #111827; + --accent: #0f766e; + --warning: #b45309; + --success: #166534; + --shadow: 0 12px 32px rgba(15, 23, 42, 0.06); + --radius-sm: 10px; + --radius-md: 14px; + --radius-lg: 18px; +} + +html, body { + background: var(--bg); + color: var(--text); + font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + min-height: 100%; +} + body { - background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab); - background-size: 400% 400%; - animation: gradient 15s ease infinite; - color: #212529; - font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - font-size: 14px; margin: 0; +} + +a { + text-decoration: none; +} + +.app-shell { min-height: 100vh; } -.main-wrapper { - display: flex; +.topbar { + background: rgba(255, 255, 255, 0.96); + backdrop-filter: blur(10px); + position: sticky; + top: 0; + z-index: 1040; +} + +.brand-mark { + width: 42px; + height: 42px; + border-radius: 12px; + display: inline-flex; align-items: center; justify-content: center; - min-height: 100vh; - width: 100%; - padding: 20px; - box-sizing: border-box; - position: relative; - z-index: 1; -} - -@keyframes gradient { - 0% { - background-position: 0% 50%; - } - 50% { - background-position: 100% 50%; - } - 100% { - background-position: 0% 50%; - } -} - -.chat-container { - width: 100%; - max-width: 600px; - background: rgba(255, 255, 255, 0.85); - border: 1px solid rgba(255, 255, 255, 0.3); - border-radius: 20px; - display: flex; - flex-direction: column; - height: 85vh; - box-shadow: 0 20px 40px rgba(0,0,0,0.2); - backdrop-filter: blur(15px); - -webkit-backdrop-filter: blur(15px); - overflow: hidden; -} - -.chat-header { - padding: 1.5rem; - border-bottom: 1px solid rgba(0, 0, 0, 0.05); - background: rgba(255, 255, 255, 0.5); + background: var(--primary); + color: #fff; font-weight: 700; - font-size: 1.1rem; - display: flex; - justify-content: space-between; - align-items: center; + letter-spacing: -0.02em; } -.chat-messages { - flex: 1; - overflow-y: auto; - padding: 1.5rem; - display: flex; - flex-direction: column; - gap: 1.25rem; +.brand-title { + font-size: 1.05rem; + font-weight: 700; } -/* Custom Scrollbar */ -::-webkit-scrollbar { - width: 6px; +.eyebrow, +.section-kicker { + font-size: 0.75rem; + letter-spacing: 0.08em; + color: var(--muted); + font-weight: 700; } -::-webkit-scrollbar-track { - background: transparent; +.app-nav { + gap: 0.5rem; } -::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.3); - border-radius: 10px; -} - -::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.5); -} - -.message { - max-width: 85%; - padding: 0.85rem 1.1rem; - border-radius: 16px; - line-height: 1.5; - font-size: 0.95rem; - box-shadow: 0 4px 15px rgba(0,0,0,0.05); - animation: fadeIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); -} - -@keyframes fadeIn { - from { opacity: 0; transform: translateY(20px) scale(0.95); } - to { opacity: 1; transform: translateY(0) scale(1); } -} - -.message.visitor { - align-self: flex-end; - background: linear-gradient(135deg, #212529 0%, #343a40 100%); - color: #fff; - border-bottom-right-radius: 4px; -} - -.message.bot { - align-self: flex-start; - background: #ffffff; - color: #212529; - border-bottom-left-radius: 4px; -} - -.chat-input-area { - padding: 1.25rem; - background: rgba(255, 255, 255, 0.5); - border-top: 1px solid rgba(0, 0, 0, 0.05); -} - -.chat-input-area form { - display: flex; - gap: 0.75rem; -} - -.chat-input-area input { - flex: 1; - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: 12px; - padding: 0.75rem 1rem; - outline: none; - background: rgba(255, 255, 255, 0.9); - transition: all 0.3s ease; -} - -.chat-input-area input:focus { - border-color: #23a6d5; - box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2); -} - -.chat-input-area button { - background: #212529; - color: #fff; - border: none; - padding: 0.75rem 1.5rem; - border-radius: 12px; - cursor: pointer; +.app-nav .nav-link { + border-radius: 999px; + color: var(--muted); font-weight: 600; - transition: all 0.3s ease; + padding: 0.6rem 0.95rem; } -.chat-input-area button:hover { - background: #000; - transform: translateY(-2px); - box-shadow: 0 5px 15px rgba(0,0,0,0.2); +.app-nav .nav-link.active, +.app-nav .nav-link:hover { + background: var(--primary); + color: #fff; } -/* Background Animations */ -.bg-animations { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 0; - overflow: hidden; +.app-nav .nav-link-muted { + color: var(--muted); pointer-events: none; } -.blob { - position: absolute; - width: 500px; - height: 500px; - background: rgba(255, 255, 255, 0.2); - border-radius: 50%; - filter: blur(80px); - animation: move 20s infinite alternate cubic-bezier(0.45, 0, 0.55, 1); +.page-title { + font-size: clamp(1.7rem, 3vw, 2.35rem); + line-height: 1.15; + font-weight: 700; + letter-spacing: -0.03em; } -.blob-1 { - top: -10%; - left: -10%; - background: rgba(238, 119, 82, 0.4); +.page-lead { + color: var(--muted); + max-width: 68ch; + line-height: 1.8; } -.blob-2 { - bottom: -10%; - right: -10%; - background: rgba(35, 166, 213, 0.4); - animation-delay: -7s; - width: 600px; - height: 600px; +.panel-card { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow); + background: var(--surface); } -.blob-3 { - top: 40%; - left: 30%; - background: rgba(231, 60, 126, 0.3); - animation-delay: -14s; - width: 450px; - height: 450px; +.hero-panel { + background: linear-gradient(180deg, #ffffff 0%, #fbfbfc 100%); } -@keyframes move { - 0% { transform: translate(0, 0) rotate(0deg) scale(1); } - 33% { transform: translate(150px, 100px) rotate(120deg) scale(1.1); } - 66% { transform: translate(-50px, 200px) rotate(240deg) scale(0.9); } - 100% { transform: translate(0, 0) rotate(360deg) scale(1); } -} - -.header-link { - font-size: 14px; - color: #fff; - text-decoration: none; - background: rgba(0, 0, 0, 0.2); - padding: 0.5rem 1rem; - border-radius: 8px; - transition: all 0.3s ease; -} - -.header-link:hover { - background: rgba(0, 0, 0, 0.4); - text-decoration: none; -} - -/* Admin Styles */ -.admin-container { - max-width: 900px; - margin: 3rem auto; - padding: 2.5rem; - background: rgba(255, 255, 255, 0.85); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - border-radius: 24px; - box-shadow: 0 20px 50px rgba(0,0,0,0.15); - border: 1px solid rgba(255, 255, 255, 0.4); - position: relative; - z-index: 1; -} - -.admin-container h1 { - margin-top: 0; - color: #212529; - font-weight: 800; -} - -.table { - width: 100%; - border-collapse: separate; - border-spacing: 0 8px; - margin-top: 1.5rem; -} - -.table th { - background: transparent; - border: none; +.metric-card { + border: 1px solid var(--border); + background: var(--surface-muted); + border-radius: var(--radius-md); padding: 1rem; - color: #6c757d; - font-weight: 600; - text-transform: uppercase; - font-size: 0.75rem; - letter-spacing: 1px; + height: 100%; } -.table td { - background: #fff; - padding: 1rem; - border: none; +.metric-label, +.mini-stat span, +.summary-row span { + color: var(--muted); + font-size: 0.86rem; } -.table tr td:first-child { border-radius: 12px 0 0 12px; } -.table tr td:last-child { border-radius: 0 12px 12px 0; } - -.form-group { - margin-bottom: 1.25rem; +.metric-value { + font-size: 1.55rem; + font-weight: 700; + margin-top: 0.35rem; + margin-bottom: 0.25rem; + letter-spacing: -0.02em; } -.form-group label { - display: block; - margin-bottom: 0.5rem; - font-weight: 600; - font-size: 0.9rem; +.metric-note { + color: var(--muted); + font-size: 0.84rem; } -.form-control { - width: 100%; - padding: 0.75rem 1rem; - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: 12px; - background: #fff; - transition: all 0.3s ease; - box-sizing: border-box; +.section-title { + font-size: 1.15rem; + font-weight: 700; + letter-spacing: -0.02em; } -.form-control:focus { - outline: none; - border-color: #23a6d5; - box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1); +.small-title { + font-size: 1rem; } -.header-container { +.stack-list, +.price-list, +.summary-grid { + display: grid; + gap: 0.85rem; +} + +.stack-item, +.price-item, +.summary-row, +.mini-stat { display: flex; - justify-content: space-between; align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.8rem 0.9rem; + background: var(--surface-muted); + border: 1px solid var(--border); + border-radius: var(--radius-sm); } -.header-links { - display: flex; +.summary-row.total { + background: #eef2f7; +} + +.status-badge { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + padding: 0.32rem 0.65rem; + font-size: 0.76rem; + font-weight: 700; + border: 1px solid transparent; + white-space: nowrap; +} + +.status-badge.success { + background: #ecfdf3; + color: var(--success); + border-color: #bbf7d0; +} + +.status-badge.pending { + background: #fff7ed; + color: var(--warning); + border-color: #fed7aa; +} + +.status-badge.neutral { + background: #eff6ff; + color: #1d4ed8; + border-color: #bfdbfe; +} + +.status-badge.warning { + background: #fff7ed; + color: var(--warning); + border-color: #fdba74; +} + +.chart-list { + display: grid; gap: 1rem; } -.admin-card { - background: rgba(255, 255, 255, 0.6); - padding: 2rem; - border-radius: 20px; - border: 1px solid rgba(255, 255, 255, 0.5); - margin-bottom: 2.5rem; - box-shadow: 0 10px 30px rgba(0,0,0,0.05); +.chart-row { + display: grid; + grid-template-columns: 96px minmax(0, 1fr) 96px; + gap: 0.85rem; + align-items: center; } -.admin-card h3 { - margin-top: 0; - margin-bottom: 1.5rem; +.chart-meta, +.chart-value { + font-size: 0.88rem; +} + +.chart-track { + height: 12px; + background: #e2e8f0; + border-radius: 999px; + overflow: hidden; +} + +.chart-bar { + height: 100%; + background: #111827; + border-radius: 999px; +} + +.app-table thead th { + color: var(--muted); + font-size: 0.78rem; font-weight: 700; -} - -.btn-delete { - background: #dc3545; - color: white; - border: none; - padding: 0.25rem 0.5rem; - border-radius: 4px; - cursor: pointer; -} - -.btn-add { - background: #212529; - color: white; - border: none; - padding: 0.5rem 1rem; - border-radius: 4px; - cursor: pointer; - margin-top: 1rem; -} - -.btn-save { - background: #0088cc; - color: white; - border: none; - padding: 0.8rem 1.5rem; - border-radius: 12px; - cursor: pointer; - font-weight: 600; - width: 100%; - transition: all 0.3s ease; -} - -.webhook-url { - font-size: 0.85em; - color: #555; - margin-top: 0.5rem; -} - -.history-table-container { - overflow-x: auto; - background: rgba(255, 255, 255, 0.4); - padding: 1rem; - border-radius: 12px; - border: 1px solid rgba(255, 255, 255, 0.3); -} - -.history-table { - width: 100%; -} - -.history-table-time { - width: 15%; + border-bottom-color: var(--border); white-space: nowrap; - font-size: 0.85em; - color: #555; } -.history-table-user { - width: 35%; - background: rgba(255, 255, 255, 0.3); - border-radius: 8px; - padding: 8px; +.app-table tbody td { + border-bottom-color: var(--border); + padding-top: 1rem; + padding-bottom: 1rem; } -.history-table-ai { - width: 50%; - background: rgba(255, 255, 255, 0.5); - border-radius: 8px; - padding: 8px; -} - -.no-messages { +.empty-state { + padding: 2.5rem 1rem; text-align: center; - color: #777; -} \ No newline at end of file +} + +.empty-state.compact { + padding: 1.25rem 0.5rem; +} + +.empty-title { + font-weight: 700; + margin-bottom: 0.35rem; +} + +.empty-copy { + color: var(--muted); +} + +.summary-card .alert { + border-radius: var(--radius-sm); +} + +.form-control, +.form-select { + min-height: 46px; + border-radius: var(--radius-sm); + border-color: #d1d5db; + padding-inline: 0.9rem; +} + +.form-control:focus, +.form-select:focus, +.btn:focus { + box-shadow: 0 0 0 0.2rem rgba(17, 24, 39, 0.12); + border-color: #9ca3af; +} + +.btn { + border-radius: 10px; + padding: 0.68rem 1rem; + font-weight: 600; +} + +.btn-dark { + background: var(--primary); + border-color: var(--primary); +} + +.btn-outline-secondary { + border-color: #d1d5db; + color: var(--text); +} + +.footer-meta { + background: rgba(255, 255, 255, 0.92); + color: var(--muted); +} + +.app-toast { + border-radius: var(--radius-sm); + box-shadow: var(--shadow); +} + +@media print { + .topbar, + .footer-meta, + .btn, + .toast-container { + display: none !important; + } + + body { + background: #fff; + } + + .container-xxl { + max-width: 100% !important; + } + + .panel-card { + box-shadow: none; + border-color: #d1d5db; + } +} + +@media (max-width: 991.98px) { + .chart-row { + grid-template-columns: 1fr; + } + + .app-nav .nav-link-muted { + width: 100%; + padding-inline: 0; + } +} + + +.pricing-picker { + display: grid; + gap: 0.85rem; +} + +.pricing-option, +.customer-list-card { + display: grid; + grid-template-columns: auto minmax(0, 1fr) minmax(140px, 180px); + gap: 0.85rem; + align-items: start; + padding: 0.9rem; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface-muted); +} + +.customer-list-card { + grid-template-columns: 1fr; + background: var(--surface); +} + +.pricing-option .form-check-input { + margin-top: 0.35rem; +} + +.pricing-option-price .form-control { + min-height: 42px; +} + +.compact-list { + gap: 0.6rem; +} + +@media (max-width: 767.98px) { + .pricing-option { + grid-template-columns: auto 1fr; + } + + .pricing-option-price { + grid-column: 1 / -1; + } +} + +.product-inline-note { + padding: 0.7rem 0.85rem; + border: 1px dashed var(--border); + border-radius: var(--radius-md); + background: var(--surface-muted); + color: var(--ink); +} diff --git a/assets/js/main.js b/assets/js/main.js index d349598..30cf1d9 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -1,39 +1,116 @@ document.addEventListener('DOMContentLoaded', () => { - const chatForm = document.getElementById('chat-form'); - const chatInput = document.getElementById('chat-input'); - const chatMessages = document.getElementById('chat-messages'); + if (window.bootstrap) { + document.querySelectorAll('.toast').forEach((element) => { + const toast = new bootstrap.Toast(element); + toast.show(); + }); + } - const appendMessage = (text, sender) => { - const msgDiv = document.createElement('div'); - msgDiv.classList.add('message', sender); - msgDiv.textContent = text; - chatMessages.appendChild(msgDiv); - chatMessages.scrollTop = chatMessages.scrollHeight; + const catalogNode = document.getElementById('catalog-data'); + const customerSelect = document.getElementById('customer_id'); + const productSelect = document.getElementById('product_id'); + const qtyInput = document.getElementById('qty'); + const submitButton = document.getElementById('submit-order-btn'); + const hintBox = document.getElementById('sales-order-hint'); + + if (!catalogNode || !customerSelect || !productSelect || !qtyInput) { + return; + } + + let catalog = {}; + try { + catalog = JSON.parse(catalogNode.textContent || '{}'); + } catch (error) { + console.error('Failed to parse catalog JSON', error); + } + + const summaryCustomer = document.getElementById('summary-customer'); + const summaryBranch = document.getElementById('summary-branch'); + const summaryPrice = document.getElementById('summary-price'); + const summaryStock = document.getElementById('summary-stock'); + const summaryMargin = document.getElementById('summary-margin'); + const summaryTotal = document.getElementById('summary-total'); + + const formatMoney = (value) => `${Number(value).toFixed(2)} ر.س`; + + const getSelectedCustomer = () => catalog[customerSelect.value] || null; + const getSelectedProduct = () => { + const current = getSelectedCustomer(); + if (!current) return null; + return current.items.find((item) => String(item.product_id) === productSelect.value) || null; }; - chatForm.addEventListener('submit', async (e) => { - e.preventDefault(); - const message = chatInput.value.trim(); - if (!message) return; + const resetSummary = () => { + summaryCustomer.textContent = '—'; + summaryBranch.textContent = '—'; + summaryPrice.textContent = '—'; + summaryStock.textContent = '—'; + summaryMargin.textContent = '—'; + summaryTotal.textContent = '—'; + }; - appendMessage(message, 'visitor'); - chatInput.value = ''; - - try { - const response = await fetch('api/chat.php', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message }) - }); - const data = await response.json(); - - // Artificial delay for realism - setTimeout(() => { - appendMessage(data.reply, 'bot'); - }, 500); - } catch (error) { - console.error('Error:', error); - appendMessage("Sorry, something went wrong. Please try again.", 'bot'); + const updateProducts = () => { + const current = getSelectedCustomer(); + productSelect.innerHTML = ''; + if (!current) { + productSelect.disabled = true; + productSelect.innerHTML = ''; + resetSummary(); + hintBox.textContent = 'اختر عميلاً لعرض الأصناف المسموح بها له. إذا كانت الكمية المطلوبة أكبر من المتاح سيظهر تنبيه ويتم تعطيل الإرسال.'; + submitButton.disabled = false; + return; } - }); + + summaryCustomer.textContent = current.customer_name; + summaryBranch.textContent = current.branch_name; + productSelect.disabled = false; + productSelect.innerHTML = ''; + current.items.forEach((item) => { + const option = document.createElement('option'); + option.value = item.product_id; + option.textContent = `${item.product_name} — ${item.sku}`; + productSelect.appendChild(option); + }); + resetCalculatedFields(); + }; + + const resetCalculatedFields = () => { + summaryPrice.textContent = '—'; + summaryStock.textContent = '—'; + summaryMargin.textContent = '—'; + summaryTotal.textContent = '—'; + submitButton.disabled = false; + }; + + const updateSummary = () => { + const product = getSelectedProduct(); + const qty = Number(qtyInput.value || 0); + if (!product) { + resetCalculatedFields(); + return; + } + + const price = Number(product.special_price || 0); + const margin = price - Number(product.cost_price || 0); + const total = qty > 0 ? qty * price : 0; + summaryPrice.textContent = formatMoney(price); + summaryStock.textContent = `${Number(product.stock_qty).toFixed(0)} ${product.unit}`; + summaryMargin.textContent = formatMoney(margin); + summaryTotal.textContent = formatMoney(total); + + if (qty > Number(product.stock_qty)) { + hintBox.textContent = 'الكمية المطلوبة تتجاوز المخزون الحالي. خفّض الكمية أو حدّث المخزون أولاً.'; + hintBox.className = 'alert alert-warning border mt-4 mb-0 small'; + submitButton.disabled = true; + } else { + hintBox.textContent = 'السعر الخاص تم تطبيقه تلقائياً بناءً على العميل والصنف المحدد.'; + hintBox.className = 'alert alert-light border mt-4 mb-0 small'; + submitButton.disabled = false; + } + }; + + customerSelect.addEventListener('change', updateProducts); + productSelect.addEventListener('change', updateSummary); + qtyInput.addEventListener('input', updateSummary); + updateProducts(); }); diff --git a/customers.php b/customers.php new file mode 100644 index 0000000..7328094 --- /dev/null +++ b/customers.php @@ -0,0 +1,136 @@ + +
+
+
+ Customers +

العملاء والفروع والأسعار الخاصة

+

أضفت هنا أول خطوة CRUD حقيقية: يمكنك الآن إنشاء عميل جديد، تحديد فرعه، وإسناد الأصناف المسموح بها مع السعر الخاص مباشرة، ليظهر فوراً داخل شاشة أوامر البيع.

+
+ إنشاء أمر بيع +
+
+ +
+
+
+
+
+
+ Create customer +

إضافة عميل جديد

+
+ يرتبط بالمبيعات تلقائياً +
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + +
+
+
+
اختر الصنف وأدخل سعره الخاص. فقط الأصناف المحددة ستظهر لهذا العميل داخل شاشة المبيعات.
+ +
+
+
+
+
+
+
+
+ Customer matrix +

مصفوفة العملاء الحالية

+
+ الإجمالي: +
+
+ +
+
+
+
+

+
·
+
+ نشط +
+
+ الأصناف المسموح بها + +
+
+ +
+
+
+
·
+
+
+
+ +
+
+
+ +
+
+
+
+
+ diff --git a/healthz.php b/healthz.php new file mode 100644 index 0000000..aa0d655 --- /dev/null +++ b/healthz.php @@ -0,0 +1,21 @@ +query('SELECT 1'); + echo json_encode([ + 'status' => 'ok', + 'php' => PHP_VERSION, + 'time' => gmdate('c'), + 'db' => 'connected', + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); +} catch (Throwable $e) { + http_response_code(500); + echo json_encode([ + 'status' => 'error', + 'php' => PHP_VERSION, + 'time' => gmdate('c'), + 'db' => 'failed', + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); +} diff --git a/index.php b/index.php index 7205f3d..63b1886 100644 --- a/index.php +++ b/index.php @@ -1,150 +1,257 @@ - - - - - - New Style - - - - - - - - - - - - - - - - - - - - - -
-
-

Analyzing your requirements and generating your website…

-
- Loading… +
+
+
+
+
+
+ لوحة تشغيل يومية +

ERP أولي يربط المبيعات بالمخزون فوراً

+

هذه النسخة الأولى تمنحك شاشة تنفيذ حقيقية: العملاء، الأصناف، أسعار خاصة لكل عميل، إنشاء أمر بيع، وتحديث المخزون والربحية مباشرة.

+
+ +
+
+
+
+
إجمالي العملاء
+
+
عملاء نشطون مع فروع وتسعير خاص
+
+
+
+
+
إجمالي الأصناف
+
+
مواد خام + منتجات نهائية
+
+
+
+
+
أصناف منخفضة
+
+
تحتاج ضبطًا أو توريدًا قريبًا
+
+
+
+
+
أوامر بيع اليوم
+
+
مؤكدة ومخصومة من المخزون
+
+
+
+
+
مخزون منتجات نهائية
+
+
جاهز للتسليم
+
+
+
+
+
مخزون خامات
+
+
تغذية مستقبلية للتصنيع
+
+
+
+
+
مبيعات اليوم
+
+
ربح متوقع:
+
+
+
-

AI is collecting your requirements and applying the first changes.

-

This page will update automatically as the plan is implemented.

-

Runtime: PHP — UTC

-
- - - +
+
+
+
+ حالة التفعيل +

الوحدات المتاحة الآن

+
+
+
+
+
+ Sales +
تسعير خاص + خصم مخزون
+
+ جاهز +
+
+
+ Inventory +
تحديث مباشر بعد البيع
+
+ جاهز +
+
+
+ Customers +
فروع + منتجات مسموحة
+
+ جاهز +
+
+
+ Purchasing +
أوامر شراء واستلام
+
+ التالي +
+
+
+ Manufacturing +
تحويل خامات لمنتجات
+
+ التالي +
+
+
+ Accounting +
كشف حساب وربح وخسارة
+
+ التالي +
+
+
+
+ + + +
+
+
+
+
+
+ مبيعات آخر 7 أيام +

اتجاه التنفيذ

+
+ رسم مبسط للإيراد اليومي +
+
+ + +
+
+ + طلب +
+
+
+
+
+
+ +
+
+
+
+
+
+
+ لقطة مخزون +

أصناف تحتاج متابعة

+
+ كل المخزون +
+
+ + + + + + + + + + + + + + + + + +
الصنفالفئةالمتاح
+
+
+
+
+
+
+
+
+ +
+
+
+
+ آخر أوامر البيع +

تنفيذ اليوم

+
+ إدارة أوامر البيع +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
رقم الطلبالعميلالفرعالقيمةالربح المتوقعالحالة
+
+
لا توجد أوامر بعد
+
ابدأ بأول أمر بيع لتفعيل الحركة على الداشبورد والمخزون.
+
+
مؤكدتفاصيل
+
+
+
+ diff --git a/order.php b/order.php new file mode 100644 index 0000000..d5e95a9 --- /dev/null +++ b/order.php @@ -0,0 +1,91 @@ + +
+
+
الطلب غير موجود
+
تحقق من الرابط أو ارجع إلى قائمة أوامر البيع.
+ العودة إلى أوامر البيع +
+
+ +
+
+
+ Order detail +

+

تم إنشاء الطلب لهذا العميل مع خصم المخزون فوراً. يمكنك استخدام هذه الصفحة كنقطة انطلاق للطباعة وPDF في الخطوة التالية.

+
+
+ + أمر جديد +
+
+
+ +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + +
الصنفSKUالكميةسعر الوحدةالإجمالي
+
+
+
+
+
+
+
العميل
+
الفرع
+
الهاتف
+
تاريخ الإنشاء
+
الحالةمؤكد
+
إجمالي البيع
+
الربح المتوقع
+
+ +
+ ملاحظات + +
+ +
+
+
+
+ diff --git a/products.php b/products.php new file mode 100644 index 0000000..1e141d3 --- /dev/null +++ b/products.php @@ -0,0 +1,338 @@ + 0) { + $redirect = 'products.php?edit=' . $productId; + } + } elseif ($action === 'adjust_stock') { + $productId = (int) ($_POST['product_id'] ?? 0); + $result = adjust_product_stock( + $productId, + (string) ($_POST['direction'] ?? ''), + (float) ($_POST['qty'] ?? 0), + (string) ($_POST['reason'] ?? ''), + (string) ($_POST['notes'] ?? '') + ); + if (!$result['success'] && $productId > 0) { + $redirect = 'products.php?adjust=' . $productId; + } + } else { + $result = ['success' => false, 'message' => 'الإجراء المطلوب غير معروف.']; + } + + flash($result['success'] ? 'success' : 'danger', $result['message']); + header('Location: ' . $redirect); + exit; +} + +$editId = (int) ($_GET['edit'] ?? 0); +$adjustId = (int) ($_GET['adjust'] ?? 0); +$editingProduct = $editId > 0 ? fetch_product_by_id($editId) : null; +$products = fetch_products(); +$productOptions = fetch_product_options(); +$movements = fetch_inventory_movements(12); + +$finished = 0; +$raw = 0; +$low = 0; +$totalStock = 0.0; +foreach ($products as $product) { + $qty = (float) $product['stock_qty']; + $totalStock += $qty; + if ($product['category'] === 'finished') { + $finished++; + } else { + $raw++; + } + if ($qty <= 40) { + $low++; + } +} + +$productForm = [ + 'sku' => $editingProduct['sku'] ?? '', + 'name' => $editingProduct['name'] ?? '', + 'category' => $editingProduct['category'] ?? 'finished', + 'unit' => $editingProduct['unit'] ?? '', + 'stock_qty' => '', + 'cost_price' => isset($editingProduct['cost_price']) ? format_qty((float) $editingProduct['cost_price']) : '', + 'sale_price' => isset($editingProduct['sale_price']) ? format_qty((float) $editingProduct['sale_price']) : '', +]; + +function movement_type_label(string $type): array +{ + return match ($type) { + 'opening' => ['label' => 'رصيد افتتاحي', 'class' => 'neutral'], + 'sale' => ['label' => 'بيع', 'class' => 'warning'], + 'adjustment_in' => ['label' => 'إضافة يدوية', 'class' => 'success'], + 'adjustment_out' => ['label' => 'خصم يدوي', 'class' => 'pending'], + default => ['label' => $type, 'class' => 'neutral'], + }; +} + +render_header('الأصناف والمخزون', 'products'); +?> +
+
+
+ Products & Inventory +

إدارة الأصناف وضبط المخزون

+

أصبحت الشاشة الآن تشغيلية: إضافة صنف جديد، تعديل بياناته، وضبط المخزون يدويًا مع سجل حركات واضح. كما أن أوامر البيع تسجل الآن حركة خصم تلقائية داخل نفس السجل.

+
+
+ إجمالي المخزون: + منتجات نهائية: + خامات: + منخفضة: +
+
+
+ +
+
+
+
+
+
+ +

+
+ + إلغاء التعديل + + يمكنك إنشاء خامة أو منتج نهائي مع رصيد افتتاحي + +
+
+ + + + + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
— غيّره من بطاقة ضبط المخزون
+
+ +
+ + +
+ +
+ + +
+
+ + +
+
+
التسعير الخاص للعملاء يبقى في شاشة العملاء، بينما هذه البطاقة تدير Master data للصنف نفسه.
+ +
+
+
+
+
+
+
+
+ Stock adjustment +

ضبط المخزون يدويًا

+
+ Audit trail +
+
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
كل تعديل يسجل قبل/بعد في جدول الحركات، لذلك ستتمكن لاحقًا من ربطه بالمشتريات والتصنيع بسهولة.
+ +
+
+
+
+
+
+ +
+
+
+
+ Product master +

كل الأصناف

+
+ تعديل البيانات الأساسية من هنا، وضبط الكميات من البطاقة الجانبية +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SKUالصنفالفئةالمتاحتكلفةبيع افتراضيالحالة
+
+
+
0 ? format_money((float) $product['sale_price']) : '—') ?> + + منخفض + + مستقر + + + +
+
+
+
+ +
+
+
+
+ Inventory journal +

آخر حركات المخزون

+
+ يشمل الرصيد الافتتاحي، التعديلات اليدوية، وخصومات البيع الجديدة +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
الوقتالصنفالنوعالتغييرقبل ← بعدمرجع / ملاحظات
+
+
لا توجد حركات حتى الآن
+
أضف صنفًا برصيد افتتاحي أو نفّذ ضبط مخزون أو أمر بيع لتظهر هنا أول حركة.
+
+
+
+
+
= 0 ? '+' : '') . format_qty($delta)) ?> +
+
+
+
+
+
+ diff --git a/sales.php b/sales.php new file mode 100644 index 0000000..bfe22f4 --- /dev/null +++ b/sales.php @@ -0,0 +1,169 @@ + +
+
+
+ Sales workflow +

إنشاء أمر بيع مع تسعير خاص وتحديث مخزون

+

اختر العميل أولاً، وستظهر فقط الأصناف المسموح بها له مع السعر الخاص المخزن في النظام. عند التأكيد يتم حفظ الطلب وخصم المخزون مباشرة.

+
+
+ عملاء مفعّلون: + خيارات بيع: +
+
+
+ +
+
+
+
+
+
+ Create +

أمر بيع جديد

+
+ الحماية مفعلة: CSRF + prepared statements + output escaping +
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
سيتم إنشاء أمر بيع مؤكد وحساب الربح المتوقع من تكلفة المنتج الحالية.
+ +
+
+
+
+
+
+
+
+ Live summary +

ملخص فوري

+
+ Auto pricing +
+
+
العميل
+
الفرع
+
السعر الخاص
+
المتاح بالمخزون
+
الربح للوحدة
+
قيمة السطر
+
+
+ اختر عميلاً لعرض الأصناف المسموح بها له. إذا كانت الكمية المطلوبة أكبر من المتاح سيظهر تنبيه ويتم تعطيل الإرسال. +
+
+
+
+
+ +
+
+
+
+ Recent orders +

قائمة أوامر البيع

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
الرقمالعميلالوقتالقيمةالربح المتوقعالإجراء
+
+
ابدأ من هنا
+
أول أمر بيع ستنشئه سيظهر هنا مع تأثيره على المخزون ولوحة التحكم.
+
+
+
+
+
عرض
+
+
+
+ +