Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
252f9561f3 |
763
app.php
Normal file
763
app.php
Normal file
@ -0,0 +1,763 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
function h(?string $value): string
|
||||
{
|
||||
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
function app_bootstrap(): void
|
||||
{
|
||||
static $booted = false;
|
||||
if ($booted) {
|
||||
return;
|
||||
}
|
||||
|
||||
$pdo = db();
|
||||
$pdo->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 '<!doctype html>';
|
||||
echo '<html lang="ar" dir="rtl">';
|
||||
echo '<head>';
|
||||
echo '<meta charset="utf-8">';
|
||||
echo '<meta name="viewport" content="width=device-width, initial-scale=1">';
|
||||
echo '<title>' . h($fullTitle) . '</title>';
|
||||
echo '<meta name="description" content="' . h($projectDescription) . '">';
|
||||
echo '<meta property="og:title" content="' . h($fullTitle) . '">';
|
||||
echo '<meta property="og:description" content="' . h($projectDescription) . '">';
|
||||
echo '<meta property="twitter:title" content="' . h($fullTitle) . '">';
|
||||
echo '<meta property="twitter:description" content="' . h($projectDescription) . '">';
|
||||
if ($projectImageUrl !== '') {
|
||||
echo '<meta property="og:image" content="' . h($projectImageUrl) . '">';
|
||||
echo '<meta property="twitter:image" content="' . h($projectImageUrl) . '">';
|
||||
}
|
||||
echo '<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.rtl.min.css" rel="stylesheet" integrity="sha384-A6Y2XxVQ1BM8DT6vKrrO5gkP7FpC18JNpDutLCRa14Q6gttxyPjdvVSKuGInxjea" crossorigin="anonymous">';
|
||||
echo '<link rel="stylesheet" href="assets/css/custom.css?v=' . h($cssVersion) . '">';
|
||||
echo '</head>';
|
||||
echo '<body>';
|
||||
echo '<div class="app-shell">';
|
||||
echo '<header class="topbar border-bottom">';
|
||||
echo '<div class="container-xxl">';
|
||||
echo '<div class="d-flex align-items-center justify-content-between gap-3 py-3 flex-wrap">';
|
||||
echo '<div class="d-flex align-items-center gap-3">';
|
||||
echo '<div class="brand-mark">NE</div>';
|
||||
echo '<div>';
|
||||
echo '<div class="eyebrow text-uppercase text-muted">Single-company ERP</div>';
|
||||
echo '<div class="brand-title">' . h(project_name()) . '</div>';
|
||||
echo '</div>';
|
||||
echo '</div>';
|
||||
echo '<div class="d-flex align-items-center gap-2 flex-wrap">';
|
||||
echo '<span class="badge text-bg-light border text-dark fw-medium">Admin Demo</span>';
|
||||
echo '<a class="btn btn-sm btn-outline-secondary" href="healthz.php">Health</a>';
|
||||
echo '</div>';
|
||||
echo '</div>';
|
||||
echo '<nav class="nav nav-pills app-nav pb-3">';
|
||||
foreach ($nav as $key => $item) {
|
||||
$class = $key === $active ? 'nav-link active' : 'nav-link';
|
||||
echo '<a class="' . $class . '" href="' . h($item['href']) . '">' . h($item['label']) . '</a>';
|
||||
}
|
||||
echo '<span class="nav-link nav-link-muted ms-auto">المشتريات · التصنيع · المحاسبة <span class="text-muted">التالي</span></span>';
|
||||
echo '</nav>';
|
||||
echo '</div>';
|
||||
echo '</header>';
|
||||
echo '<main class="page-content">';
|
||||
echo '<div class="container-xxl py-4 py-lg-5">';
|
||||
}
|
||||
|
||||
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 '<div class="toast-container position-fixed top-0 start-0 p-3">';
|
||||
echo '<div class="toast align-items-center text-bg-' . h($flash['type']) . ' border-0 app-toast" role="alert" aria-live="assertive" aria-atomic="true" data-bs-delay="5000">';
|
||||
echo '<div class="d-flex">';
|
||||
echo '<div class="toast-body">' . h($flash['message']) . '</div>';
|
||||
echo '<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>';
|
||||
echo '</div></div></div>';
|
||||
}
|
||||
echo '</div>';
|
||||
echo '</main>';
|
||||
echo '<footer class="border-top py-3 footer-meta">';
|
||||
echo '<div class="container-xxl d-flex justify-content-between align-items-center flex-wrap gap-2">';
|
||||
echo '<span>شريحة MVP أولية لعمليات البيع والمخزون.</span>';
|
||||
echo '<span class="text-muted">جاهز للتوسعة إلى المشتريات، التصنيع، والمحاسبة.</span>';
|
||||
echo '</div>';
|
||||
echo '</footer>';
|
||||
echo '</div>';
|
||||
echo '<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+ojpM1ylI2QvZ6jIW3" crossorigin="anonymous"></script>';
|
||||
echo '<script src="assets/js/main.js?v=' . h($jsVersion) . '"></script>';
|
||||
echo '</body></html>';
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
@ -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 = '<option value="">اختر العميل أولاً</option>';
|
||||
resetSummary();
|
||||
hintBox.textContent = 'اختر عميلاً لعرض الأصناف المسموح بها له. إذا كانت الكمية المطلوبة أكبر من المتاح سيظهر تنبيه ويتم تعطيل الإرسال.';
|
||||
submitButton.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
summaryCustomer.textContent = current.customer_name;
|
||||
summaryBranch.textContent = current.branch_name;
|
||||
productSelect.disabled = false;
|
||||
productSelect.innerHTML = '<option value="">اختر الصنف</option>';
|
||||
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();
|
||||
});
|
||||
|
||||
136
customers.php
Normal file
136
customers.php
Normal file
@ -0,0 +1,136 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/app.php';
|
||||
app_bootstrap();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!verify_csrf($_POST['csrf_token'] ?? null)) {
|
||||
flash('danger', 'انتهت صلاحية الجلسة. أعد المحاولة.');
|
||||
header('Location: customers.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$result = create_customer_with_prices(
|
||||
(string) ($_POST['name'] ?? ''),
|
||||
(string) ($_POST['branch_name'] ?? ''),
|
||||
(string) ($_POST['phone'] ?? ''),
|
||||
is_array($_POST['selected_products'] ?? null) ? $_POST['selected_products'] : [],
|
||||
is_array($_POST['special_price'] ?? null) ? $_POST['special_price'] : []
|
||||
);
|
||||
|
||||
flash($result['success'] ? 'success' : 'danger', $result['message']);
|
||||
header('Location: customers.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$customers = fetch_customers_with_catalog();
|
||||
$finishedProducts = fetch_finished_products_for_pricing();
|
||||
render_header('العملاء', 'customers');
|
||||
?>
|
||||
<section class="mb-4">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-4">
|
||||
<div>
|
||||
<span class="section-kicker">Customers</span>
|
||||
<h1 class="page-title mb-2">العملاء والفروع والأسعار الخاصة</h1>
|
||||
<p class="page-lead mb-0">أضفت هنا أول خطوة CRUD حقيقية: يمكنك الآن إنشاء عميل جديد، تحديد فرعه، وإسناد الأصناف المسموح بها مع السعر الخاص مباشرة، ليظهر فوراً داخل شاشة أوامر البيع.</p>
|
||||
</div>
|
||||
<a class="btn btn-dark" href="sales.php">إنشاء أمر بيع</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-4 mb-lg-5">
|
||||
<div class="row g-4 align-items-start">
|
||||
<div class="col-lg-5">
|
||||
<div class="card panel-card h-100">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
||||
<div>
|
||||
<span class="section-kicker">Create customer</span>
|
||||
<h2 class="section-title mb-0">إضافة عميل جديد</h2>
|
||||
</div>
|
||||
<span class="status-badge success">يرتبط بالمبيعات تلقائياً</span>
|
||||
</div>
|
||||
<form method="post" class="row g-3">
|
||||
<input type="hidden" name="csrf_token" value="<?= h(csrf_token()) ?>">
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="name">اسم العميل</label>
|
||||
<input class="form-control" type="text" id="name" name="name" maxlength="160" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="branch_name">الفرع</label>
|
||||
<input class="form-control" type="text" id="branch_name" name="branch_name" maxlength="160" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="phone">الهاتف</label>
|
||||
<input class="form-control" type="text" id="phone" name="phone" maxlength="40" placeholder="+9665XXXXXXXX">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label d-block mb-3">الأصناف المسموح بها والسعر الخاص</label>
|
||||
<div class="pricing-picker">
|
||||
<?php foreach ($finishedProducts as $product): ?>
|
||||
<label class="pricing-option">
|
||||
<div class="form-check m-0">
|
||||
<input class="form-check-input" type="checkbox" name="selected_products[]" value="<?= h((string) $product['id']) ?>">
|
||||
</div>
|
||||
<div class="pricing-option-body">
|
||||
<div class="fw-semibold"><?= h($product['name']) ?></div>
|
||||
<div class="text-muted small"><?= h($product['sku']) ?> · <?= h($product['unit']) ?> · متاح <?= h(format_qty((float) $product['stock_qty'])) ?></div>
|
||||
</div>
|
||||
<div class="pricing-option-price">
|
||||
<input class="form-control" type="number" step="0.01" min="0" name="special_price[<?= h((string) $product['id']) ?>]" placeholder="سعر خاص">
|
||||
<div class="text-muted small mt-1">الافتراضي: <?= h(format_money((float) $product['sale_price'])) ?></div>
|
||||
</div>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 d-flex justify-content-between align-items-center flex-wrap gap-2 mt-2">
|
||||
<div class="text-muted small">اختر الصنف وأدخل سعره الخاص. فقط الأصناف المحددة ستظهر لهذا العميل داخل شاشة المبيعات.</div>
|
||||
<button class="btn btn-dark" type="submit">حفظ العميل</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-7">
|
||||
<div class="card panel-card h-100">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
||||
<div>
|
||||
<span class="section-kicker">Customer matrix</span>
|
||||
<h2 class="section-title mb-0">مصفوفة العملاء الحالية</h2>
|
||||
</div>
|
||||
<span class="badge text-bg-light border text-dark">الإجمالي: <?= h((string) count($customers)) ?></span>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<?php foreach ($customers as $customer): ?>
|
||||
<div class="col-12">
|
||||
<div class="customer-list-card">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-3">
|
||||
<div>
|
||||
<h3 class="section-title small-title mb-1"><?= h($customer['name']) ?></h3>
|
||||
<div class="text-muted small"><?= h($customer['branch_name']) ?> · <span dir="ltr"><?= h($customer['phone'] ?: '—') ?></span></div>
|
||||
</div>
|
||||
<span class="status-badge success">نشط</span>
|
||||
</div>
|
||||
<div class="mini-stat mb-3">
|
||||
<span>الأصناف المسموح بها</span>
|
||||
<strong><?= h((string) count($customer['catalog'])) ?></strong>
|
||||
</div>
|
||||
<div class="price-list compact-list">
|
||||
<?php foreach ($customer['catalog'] as $item): ?>
|
||||
<div class="price-item">
|
||||
<div>
|
||||
<div class="fw-semibold"><?= h($item['name']) ?></div>
|
||||
<div class="text-muted small"><?= h($item['sku']) ?> · <?= h($item['unit']) ?></div>
|
||||
</div>
|
||||
<div class="fw-semibold"><?= h(format_money((float) $item['special_price'])) ?></div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php render_footer(); ?>
|
||||
21
healthz.php
Normal file
21
healthz.php
Normal file
@ -0,0 +1,21 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
try {
|
||||
db()->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);
|
||||
}
|
||||
395
index.php
395
index.php
@ -1,150 +1,257 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
@ini_set('display_errors', '1');
|
||||
@error_reporting(E_ALL);
|
||||
@date_default_timezone_set('UTC');
|
||||
require_once __DIR__ . '/app.php';
|
||||
app_bootstrap();
|
||||
|
||||
$phpVersion = PHP_VERSION;
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$metrics = fetch_dashboard_metrics();
|
||||
$trend = fetch_sales_trend();
|
||||
$recentOrders = fetch_recent_orders(6);
|
||||
$products = fetch_products();
|
||||
$maxRevenue = 1.0;
|
||||
foreach ($trend as $point) {
|
||||
$maxRevenue = max($maxRevenue, (float) $point['revenue']);
|
||||
}
|
||||
|
||||
render_header('لوحة التحكم', 'dashboard');
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>New Style</title>
|
||||
<?php
|
||||
// Read project preview data from environment
|
||||
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
|
||||
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
|
||||
?>
|
||||
<?php if ($projectDescription): ?>
|
||||
<!-- Meta description -->
|
||||
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
|
||||
<!-- Open Graph meta tags -->
|
||||
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
||||
<!-- Twitter meta tags -->
|
||||
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
||||
<?php endif; ?>
|
||||
<?php if ($projectImageUrl): ?>
|
||||
<!-- Open Graph image -->
|
||||
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
||||
<!-- Twitter image -->
|
||||
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
||||
<?php endif; ?>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-color-start: #6a11cb;
|
||||
--bg-color-end: #2575fc;
|
||||
--text-color: #ffffff;
|
||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
body::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
|
||||
animation: bg-pan 20s linear infinite;
|
||||
z-index: -1;
|
||||
}
|
||||
@keyframes bg-pan {
|
||||
0% { background-position: 0% 0%; }
|
||||
100% { background-position: 100% 100%; }
|
||||
}
|
||||
main {
|
||||
padding: 2rem;
|
||||
}
|
||||
.card {
|
||||
background: var(--card-bg-color);
|
||||
border: 1px solid var(--card-border-color);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.loader {
|
||||
margin: 1.25rem auto 1.25rem;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.25);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.hint {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px; height: 1px;
|
||||
padding: 0; margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap; border: 0;
|
||||
}
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 1rem;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
p {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
code {
|
||||
background: rgba(0,0,0,0.2);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
footer {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="card">
|
||||
<h1>Analyzing your requirements and generating your website…</h1>
|
||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
||||
<span class="sr-only">Loading…</span>
|
||||
<section class="mb-4 mb-lg-5">
|
||||
<div class="row g-4 align-items-stretch">
|
||||
<div class="col-lg-8">
|
||||
<div class="card panel-card hero-panel h-100">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-4">
|
||||
<div>
|
||||
<span class="section-kicker">لوحة تشغيل يومية</span>
|
||||
<h1 class="page-title mb-2">ERP أولي يربط المبيعات بالمخزون فوراً</h1>
|
||||
<p class="page-lead mb-0">هذه النسخة الأولى تمنحك شاشة تنفيذ حقيقية: العملاء، الأصناف، أسعار خاصة لكل عميل، إنشاء أمر بيع، وتحديث المخزون والربحية مباشرة.</p>
|
||||
</div>
|
||||
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p>
|
||||
<p class="hint">This page will update automatically as the plan is implemented.</p>
|
||||
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<a class="btn btn-dark" href="sales.php">أمر بيع جديد</a>
|
||||
<a class="btn btn-outline-secondary" href="customers.php">مصفوفة العملاء</a>
|
||||
</div>
|
||||
</main>
|
||||
<footer>
|
||||
Page updated: <?= htmlspecialchars($now) ?> (UTC)
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
</div>
|
||||
<div class="row g-3 metric-grid">
|
||||
<div class="col-sm-6 col-xl-4">
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">إجمالي العملاء</div>
|
||||
<div class="metric-value"><?= h((string) $metrics['customers']) ?></div>
|
||||
<div class="metric-note">عملاء نشطون مع فروع وتسعير خاص</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-4">
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">إجمالي الأصناف</div>
|
||||
<div class="metric-value"><?= h((string) $metrics['products']) ?></div>
|
||||
<div class="metric-note">مواد خام + منتجات نهائية</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-4">
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">أصناف منخفضة</div>
|
||||
<div class="metric-value"><?= h((string) $metrics['low_stock_products']) ?></div>
|
||||
<div class="metric-note">تحتاج ضبطًا أو توريدًا قريبًا</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-4">
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">أوامر بيع اليوم</div>
|
||||
<div class="metric-value"><?= h((string) $metrics['orders_today']) ?></div>
|
||||
<div class="metric-note">مؤكدة ومخصومة من المخزون</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-4">
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">مخزون منتجات نهائية</div>
|
||||
<div class="metric-value"><?= h(format_qty((float) $metrics['finished_stock'])) ?></div>
|
||||
<div class="metric-note">جاهز للتسليم</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-4">
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">مخزون خامات</div>
|
||||
<div class="metric-value"><?= h(format_qty((float) $metrics['raw_stock'])) ?></div>
|
||||
<div class="metric-note">تغذية مستقبلية للتصنيع</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-4">
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">مبيعات اليوم</div>
|
||||
<div class="metric-value"><?= h(format_money((float) $metrics['sales_today'])) ?></div>
|
||||
<div class="metric-note">ربح متوقع: <?= h(format_money((float) $metrics['profit_today'])) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<div class="card panel-card h-100">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<span class="section-kicker">حالة التفعيل</span>
|
||||
<h2 class="section-title mb-0">الوحدات المتاحة الآن</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stack-list">
|
||||
<div class="stack-item">
|
||||
<div>
|
||||
<strong>Sales</strong>
|
||||
<div class="text-muted small">تسعير خاص + خصم مخزون</div>
|
||||
</div>
|
||||
<span class="status-badge success">جاهز</span>
|
||||
</div>
|
||||
<div class="stack-item">
|
||||
<div>
|
||||
<strong>Inventory</strong>
|
||||
<div class="text-muted small">تحديث مباشر بعد البيع</div>
|
||||
</div>
|
||||
<span class="status-badge success">جاهز</span>
|
||||
</div>
|
||||
<div class="stack-item">
|
||||
<div>
|
||||
<strong>Customers</strong>
|
||||
<div class="text-muted small">فروع + منتجات مسموحة</div>
|
||||
</div>
|
||||
<span class="status-badge success">جاهز</span>
|
||||
</div>
|
||||
<div class="stack-item">
|
||||
<div>
|
||||
<strong>Purchasing</strong>
|
||||
<div class="text-muted small">أوامر شراء واستلام</div>
|
||||
</div>
|
||||
<span class="status-badge pending">التالي</span>
|
||||
</div>
|
||||
<div class="stack-item">
|
||||
<div>
|
||||
<strong>Manufacturing</strong>
|
||||
<div class="text-muted small">تحويل خامات لمنتجات</div>
|
||||
</div>
|
||||
<span class="status-badge pending">التالي</span>
|
||||
</div>
|
||||
<div class="stack-item">
|
||||
<div>
|
||||
<strong>Accounting</strong>
|
||||
<div class="text-muted small">كشف حساب وربح وخسارة</div>
|
||||
</div>
|
||||
<span class="status-badge pending">التالي</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-4 mb-lg-5">
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-7">
|
||||
<div class="card panel-card h-100">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
||||
<div>
|
||||
<span class="section-kicker">مبيعات آخر 7 أيام</span>
|
||||
<h2 class="section-title mb-0">اتجاه التنفيذ</h2>
|
||||
</div>
|
||||
<span class="text-muted small">رسم مبسط للإيراد اليومي</span>
|
||||
</div>
|
||||
<div class="chart-list">
|
||||
<?php foreach ($trend as $point): ?>
|
||||
<?php $width = max(6, (int) round(($point['revenue'] / $maxRevenue) * 100)); ?>
|
||||
<div class="chart-row">
|
||||
<div class="chart-meta">
|
||||
<strong><?= h($point['label']) ?></strong>
|
||||
<span class="text-muted small"><?= h((string) $point['order_count']) ?> طلب</span>
|
||||
</div>
|
||||
<div class="chart-track">
|
||||
<div class="chart-bar" style="width: <?= h((string) $width) ?>%"></div>
|
||||
</div>
|
||||
<div class="chart-value"><?= h(format_money((float) $point['revenue'])) ?></div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<div class="card panel-card h-100">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<span class="section-kicker">لقطة مخزون</span>
|
||||
<h2 class="section-title mb-0">أصناف تحتاج متابعة</h2>
|
||||
</div>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="products.php">كل المخزون</a>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle app-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>الصنف</th>
|
||||
<th>الفئة</th>
|
||||
<th>المتاح</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach (array_slice($products, 0, 5) as $product): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fw-semibold"><?= h($product['name']) ?></div>
|
||||
<div class="text-muted small"><?= h($product['sku']) ?></div>
|
||||
</td>
|
||||
<td><span class="status-badge <?= $product['category'] === 'finished' ? 'neutral' : 'pending' ?>"><?= $product['category'] === 'finished' ? 'منتج نهائي' : 'خامة' ?></span></td>
|
||||
<td><?= h(format_qty((float) $product['stock_qty'])) ?> <?= h($product['unit']) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="card panel-card">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
||||
<div>
|
||||
<span class="section-kicker">آخر أوامر البيع</span>
|
||||
<h2 class="section-title mb-0">تنفيذ اليوم</h2>
|
||||
</div>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="sales.php">إدارة أوامر البيع</a>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle app-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>رقم الطلب</th>
|
||||
<th>العميل</th>
|
||||
<th>الفرع</th>
|
||||
<th>القيمة</th>
|
||||
<th>الربح المتوقع</th>
|
||||
<th>الحالة</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (!$recentOrders): ?>
|
||||
<tr>
|
||||
<td colspan="7">
|
||||
<div class="empty-state compact">
|
||||
<div class="empty-title">لا توجد أوامر بعد</div>
|
||||
<div class="empty-copy">ابدأ بأول أمر بيع لتفعيل الحركة على الداشبورد والمخزون.</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($recentOrders as $order): ?>
|
||||
<tr>
|
||||
<td class="fw-semibold"><?= h($order['order_number']) ?></td>
|
||||
<td><?= h($order['customer_name']) ?></td>
|
||||
<td><?= h($order['branch_name']) ?></td>
|
||||
<td><?= h(format_money((float) $order['subtotal'])) ?></td>
|
||||
<td><?= h(format_money((float) $order['expected_profit'])) ?></td>
|
||||
<td><span class="status-badge success">مؤكد</span></td>
|
||||
<td><a class="btn btn-sm btn-outline-secondary" href="order.php?id=<?= h((string) $order['id']) ?>">تفاصيل</a></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php render_footer(); ?>
|
||||
|
||||
91
order.php
Normal file
91
order.php
Normal file
@ -0,0 +1,91 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/app.php';
|
||||
app_bootstrap();
|
||||
$orderId = (int) ($_GET['id'] ?? 0);
|
||||
$detail = fetch_order_detail($orderId);
|
||||
if (!$detail) {
|
||||
http_response_code(404);
|
||||
render_header('الطلب غير موجود', 'sales');
|
||||
?>
|
||||
<div class="card panel-card">
|
||||
<div class="empty-state">
|
||||
<div class="empty-title">الطلب غير موجود</div>
|
||||
<div class="empty-copy">تحقق من الرابط أو ارجع إلى قائمة أوامر البيع.</div>
|
||||
<a class="btn btn-dark mt-3" href="sales.php">العودة إلى أوامر البيع</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
render_footer();
|
||||
exit;
|
||||
}
|
||||
$order = $detail['order'];
|
||||
$items = $detail['items'];
|
||||
render_header('تفاصيل أمر البيع', 'sales');
|
||||
?>
|
||||
<section class="mb-4">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-4">
|
||||
<div>
|
||||
<span class="section-kicker">Order detail</span>
|
||||
<h1 class="page-title mb-2"><?= h($order['order_number']) ?></h1>
|
||||
<p class="page-lead mb-0">تم إنشاء الطلب لهذا العميل مع خصم المخزون فوراً. يمكنك استخدام هذه الصفحة كنقطة انطلاق للطباعة وPDF في الخطوة التالية.</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="window.print()">طباعة</button>
|
||||
<a class="btn btn-dark" href="sales.php">أمر جديد</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-8">
|
||||
<div class="card panel-card h-100">
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle app-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>الصنف</th>
|
||||
<th>SKU</th>
|
||||
<th>الكمية</th>
|
||||
<th>سعر الوحدة</th>
|
||||
<th>الإجمالي</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($items as $item): ?>
|
||||
<tr>
|
||||
<td><?= h($item['product_name']) ?></td>
|
||||
<td><?= h($item['sku']) ?></td>
|
||||
<td><?= h(format_qty((float) $item['qty'])) ?> <?= h($item['unit']) ?></td>
|
||||
<td><?= h(format_money((float) $item['unit_price'])) ?></td>
|
||||
<td class="fw-semibold"><?= h(format_money((float) $item['line_total'])) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<div class="card panel-card h-100">
|
||||
<div class="summary-grid">
|
||||
<div class="summary-row"><span>العميل</span><strong><?= h($order['customer_name']) ?></strong></div>
|
||||
<div class="summary-row"><span>الفرع</span><strong><?= h($order['branch_name']) ?></strong></div>
|
||||
<div class="summary-row"><span>الهاتف</span><strong dir="ltr"><?= h($order['phone'] ?: '—') ?></strong></div>
|
||||
<div class="summary-row"><span>تاريخ الإنشاء</span><strong><?= h(date('Y-m-d H:i', strtotime($order['created_at']))) ?></strong></div>
|
||||
<div class="summary-row"><span>الحالة</span><strong>مؤكد</strong></div>
|
||||
<div class="summary-row total"><span>إجمالي البيع</span><strong><?= h(format_money((float) $order['subtotal'])) ?></strong></div>
|
||||
<div class="summary-row"><span>الربح المتوقع</span><strong><?= h(format_money((float) $order['expected_profit'])) ?></strong></div>
|
||||
</div>
|
||||
<?php if (!empty($order['notes'])): ?>
|
||||
<div class="alert alert-light border mt-4 mb-0">
|
||||
<strong class="d-block mb-1">ملاحظات</strong>
|
||||
<?= nl2br(h($order['notes'])) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php render_footer(); ?>
|
||||
338
products.php
Normal file
338
products.php
Normal file
@ -0,0 +1,338 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/app.php';
|
||||
app_bootstrap();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!verify_csrf($_POST['csrf_token'] ?? null)) {
|
||||
flash('danger', 'انتهت صلاحية الجلسة. أعد المحاولة.');
|
||||
header('Location: products.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$action = (string) ($_POST['action'] ?? '');
|
||||
$redirect = 'products.php';
|
||||
|
||||
if ($action === 'create_product') {
|
||||
$result = create_product($_POST);
|
||||
} elseif ($action === 'update_product') {
|
||||
$productId = (int) ($_POST['product_id'] ?? 0);
|
||||
$result = update_product($productId, $_POST);
|
||||
if (!$result['success'] && $productId > 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');
|
||||
?>
|
||||
<section class="mb-4">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-4">
|
||||
<div>
|
||||
<span class="section-kicker">Products & Inventory</span>
|
||||
<h1 class="page-title mb-2">إدارة الأصناف وضبط المخزون</h1>
|
||||
<p class="page-lead mb-0">أصبحت الشاشة الآن تشغيلية: إضافة صنف جديد، تعديل بياناته، وضبط المخزون يدويًا مع سجل حركات واضح. كما أن أوامر البيع تسجل الآن حركة خصم تلقائية داخل نفس السجل.</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<span class="badge text-bg-light border text-dark">إجمالي المخزون: <?= h(format_qty($totalStock)) ?></span>
|
||||
<span class="badge text-bg-light border text-dark">منتجات نهائية: <?= h((string) $finished) ?></span>
|
||||
<span class="badge text-bg-light border text-dark">خامات: <?= h((string) $raw) ?></span>
|
||||
<span class="badge text-bg-light border text-dark">منخفضة: <?= h((string) $low) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-4 mb-lg-5">
|
||||
<div class="row g-4 align-items-stretch">
|
||||
<div class="col-xl-7">
|
||||
<div class="card panel-card h-100">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
||||
<div>
|
||||
<span class="section-kicker"><?= $editingProduct ? 'Edit product' : 'Create product' ?></span>
|
||||
<h2 class="section-title mb-0"><?= $editingProduct ? 'تعديل بيانات الصنف' : 'إضافة صنف جديد' ?></h2>
|
||||
</div>
|
||||
<?php if ($editingProduct): ?>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="products.php">إلغاء التعديل</a>
|
||||
<?php else: ?>
|
||||
<span class="text-muted small">يمكنك إنشاء خامة أو منتج نهائي مع رصيد افتتاحي</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<form method="post" class="row g-3">
|
||||
<input type="hidden" name="csrf_token" value="<?= h(csrf_token()) ?>">
|
||||
<input type="hidden" name="action" value="<?= $editingProduct ? 'update_product' : 'create_product' ?>">
|
||||
<?php if ($editingProduct): ?>
|
||||
<input type="hidden" name="product_id" value="<?= h((string) $editingProduct['id']) ?>">
|
||||
<?php endif; ?>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="sku">SKU</label>
|
||||
<input class="form-control" type="text" id="sku" name="sku" maxlength="80" required value="<?= h($productForm['sku']) ?>" placeholder="FG-104">
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<label class="form-label" for="name">اسم الصنف</label>
|
||||
<input class="form-control" type="text" id="name" name="name" maxlength="160" required value="<?= h($productForm['name']) ?>" placeholder="مثال: جل تعقيم 250 مل">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="category">الفئة</label>
|
||||
<select class="form-select" id="category" name="category" required>
|
||||
<option value="finished" <?= $productForm['category'] === 'finished' ? 'selected' : '' ?>>منتج نهائي</option>
|
||||
<option value="raw" <?= $productForm['category'] === 'raw' ? 'selected' : '' ?>>خامة</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="unit">وحدة القياس</label>
|
||||
<input class="form-control" type="text" id="unit" name="unit" maxlength="40" required value="<?= h($productForm['unit']) ?>" placeholder="كرتون / برميل / حبة">
|
||||
</div>
|
||||
<?php if ($editingProduct): ?>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">المخزون الحالي</label>
|
||||
<div class="form-control-plaintext product-inline-note"><?= h(format_qty((float) $editingProduct['stock_qty'])) ?> <?= h($editingProduct['unit']) ?> — غيّره من بطاقة ضبط المخزون</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="stock_qty">الرصيد الافتتاحي</label>
|
||||
<input class="form-control" type="number" min="0" step="0.01" id="stock_qty" name="stock_qty" value="<?= h($productForm['stock_qty']) ?>" placeholder="0">
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="cost_price">تكلفة الوحدة</label>
|
||||
<input class="form-control" type="number" min="0" step="0.01" id="cost_price" name="cost_price" required value="<?= h($productForm['cost_price']) ?>" placeholder="0.00">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="sale_price">سعر البيع الافتراضي</label>
|
||||
<input class="form-control" type="number" min="0" step="0.01" id="sale_price" name="sale_price" required value="<?= h($productForm['sale_price']) ?>" placeholder="0.00">
|
||||
</div>
|
||||
<div class="col-12 d-flex justify-content-between align-items-center gap-2 flex-wrap mt-2">
|
||||
<div class="text-muted small">التسعير الخاص للعملاء يبقى في شاشة العملاء، بينما هذه البطاقة تدير Master data للصنف نفسه.</div>
|
||||
<button class="btn btn-dark" type="submit"><?= $editingProduct ? 'حفظ التعديلات' : 'إضافة الصنف' ?></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-5">
|
||||
<div class="card panel-card h-100" id="adjust-stock-card">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
||||
<div>
|
||||
<span class="section-kicker">Stock adjustment</span>
|
||||
<h2 class="section-title mb-0">ضبط المخزون يدويًا</h2>
|
||||
</div>
|
||||
<span class="status-badge neutral">Audit trail</span>
|
||||
</div>
|
||||
<form method="post" class="row g-3">
|
||||
<input type="hidden" name="csrf_token" value="<?= h(csrf_token()) ?>">
|
||||
<input type="hidden" name="action" value="adjust_stock">
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="adjust_product_id">الصنف</label>
|
||||
<select class="form-select" id="adjust_product_id" name="product_id" required>
|
||||
<option value="">اختر الصنف</option>
|
||||
<?php foreach ($productOptions as $option): ?>
|
||||
<?php $selected = $adjustId === (int) $option['id'] ? 'selected' : ''; ?>
|
||||
<option value="<?= h((string) $option['id']) ?>" <?= $selected ?>><?= h($option['name']) ?> — <?= h($option['sku']) ?> (<?= h(format_qty((float) $option['stock_qty'])) ?> <?= h($option['unit']) ?>)</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="direction">العملية</label>
|
||||
<select class="form-select" id="direction" name="direction" required>
|
||||
<option value="add">إضافة</option>
|
||||
<option value="subtract">خصم</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="adjust_qty">الكمية</label>
|
||||
<input class="form-control" type="number" min="0.01" step="0.01" id="adjust_qty" name="qty" required placeholder="10">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="reason">السبب</label>
|
||||
<input class="form-control" type="text" id="reason" name="reason" maxlength="120" required placeholder="جرد / استلام / هالك">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="notes">ملاحظات إضافية</label>
|
||||
<textarea class="form-control" id="notes" name="notes" rows="3" maxlength="255" placeholder="وصف مختصر لسبب التعديل"></textarea>
|
||||
</div>
|
||||
<div class="col-12 d-flex justify-content-between align-items-center gap-2 flex-wrap mt-2">
|
||||
<div class="text-muted small">كل تعديل يسجل قبل/بعد في جدول الحركات، لذلك ستتمكن لاحقًا من ربطه بالمشتريات والتصنيع بسهولة.</div>
|
||||
<button class="btn btn-outline-dark" type="submit">تنفيذ التعديل</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-4 mb-lg-5">
|
||||
<div class="card panel-card">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
||||
<div>
|
||||
<span class="section-kicker">Product master</span>
|
||||
<h2 class="section-title mb-0">كل الأصناف</h2>
|
||||
</div>
|
||||
<span class="text-muted small">تعديل البيانات الأساسية من هنا، وضبط الكميات من البطاقة الجانبية</span>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle app-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>SKU</th>
|
||||
<th>الصنف</th>
|
||||
<th>الفئة</th>
|
||||
<th>المتاح</th>
|
||||
<th>تكلفة</th>
|
||||
<th>بيع افتراضي</th>
|
||||
<th>الحالة</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($products as $product): ?>
|
||||
<?php $qty = (float) $product['stock_qty']; ?>
|
||||
<tr>
|
||||
<td class="fw-semibold"><?= h($product['sku']) ?></td>
|
||||
<td>
|
||||
<div class="fw-semibold"><?= h($product['name']) ?></div>
|
||||
<div class="text-muted small"><?= h($product['unit']) ?></div>
|
||||
</td>
|
||||
<td><span class="status-badge <?= $product['category'] === 'finished' ? 'neutral' : 'pending' ?>"><?= $product['category'] === 'finished' ? 'منتج نهائي' : 'خامة' ?></span></td>
|
||||
<td><?= h(format_qty($qty)) ?> <?= h($product['unit']) ?></td>
|
||||
<td><?= h(format_money((float) $product['cost_price'])) ?></td>
|
||||
<td><?= h((float) $product['sale_price'] > 0 ? format_money((float) $product['sale_price']) : '—') ?></td>
|
||||
<td>
|
||||
<?php if ($qty <= 40): ?>
|
||||
<span class="status-badge warning">منخفض</span>
|
||||
<?php else: ?>
|
||||
<span class="status-badge success">مستقر</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex gap-2 justify-content-end flex-wrap">
|
||||
<a class="btn btn-sm btn-outline-secondary" href="products.php?edit=<?= h((string) $product['id']) ?>">تعديل</a>
|
||||
<a class="btn btn-sm btn-outline-dark" href="products.php?adjust=<?= h((string) $product['id']) ?>#adjust-stock-card">ضبط مخزون</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="card panel-card">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
||||
<div>
|
||||
<span class="section-kicker">Inventory journal</span>
|
||||
<h2 class="section-title mb-0">آخر حركات المخزون</h2>
|
||||
</div>
|
||||
<span class="text-muted small">يشمل الرصيد الافتتاحي، التعديلات اليدوية، وخصومات البيع الجديدة</span>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle app-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>الوقت</th>
|
||||
<th>الصنف</th>
|
||||
<th>النوع</th>
|
||||
<th>التغيير</th>
|
||||
<th>قبل ← بعد</th>
|
||||
<th>مرجع / ملاحظات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (!$movements): ?>
|
||||
<tr>
|
||||
<td colspan="6">
|
||||
<div class="empty-state compact">
|
||||
<div class="empty-title">لا توجد حركات حتى الآن</div>
|
||||
<div class="empty-copy">أضف صنفًا برصيد افتتاحي أو نفّذ ضبط مخزون أو أمر بيع لتظهر هنا أول حركة.</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($movements as $movement): ?>
|
||||
<?php $meta = movement_type_label((string) $movement['movement_type']); ?>
|
||||
<?php $delta = (float) $movement['qty_change']; ?>
|
||||
<tr>
|
||||
<td><?= h(date('Y-m-d H:i', strtotime($movement['created_at']))) ?></td>
|
||||
<td>
|
||||
<div class="fw-semibold"><?= h($movement['product_name']) ?></div>
|
||||
<div class="text-muted small"><?= h($movement['sku']) ?></div>
|
||||
</td>
|
||||
<td><span class="status-badge <?= h($meta['class']) ?>"><?= h($meta['label']) ?></span></td>
|
||||
<td class="<?= $delta >= 0 ? 'text-success' : 'text-danger' ?> fw-semibold"><?= h(($delta >= 0 ? '+' : '') . format_qty($delta)) ?> <?= h($movement['unit']) ?></td>
|
||||
<td><?= h(format_qty((float) $movement['stock_before'])) ?> → <?= h(format_qty((float) $movement['stock_after'])) ?></td>
|
||||
<td>
|
||||
<div><?= h($movement['reference_code'] ?: '—') ?></div>
|
||||
<div class="text-muted small"><?= h($movement['notes'] ?: 'بدون ملاحظات') ?></div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php render_footer(); ?>
|
||||
169
sales.php
Normal file
169
sales.php
Normal file
@ -0,0 +1,169 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/app.php';
|
||||
app_bootstrap();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!verify_csrf($_POST['csrf_token'] ?? null)) {
|
||||
flash('danger', 'انتهت صلاحية الجلسة. أعد المحاولة.');
|
||||
header('Location: sales.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$customerId = (int) ($_POST['customer_id'] ?? 0);
|
||||
$productId = (int) ($_POST['product_id'] ?? 0);
|
||||
$qty = (float) ($_POST['qty'] ?? 0);
|
||||
$notes = trim((string) ($_POST['notes'] ?? ''));
|
||||
$result = create_sales_order($customerId, $productId, $qty, $notes);
|
||||
flash($result['success'] ? 'success' : 'danger', $result['message']);
|
||||
if (!empty($result['success'])) {
|
||||
header('Location: order.php?id=' . (int) $result['order_id']);
|
||||
exit;
|
||||
}
|
||||
header('Location: sales.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$catalog = fetch_sales_form_catalog();
|
||||
$recentOrders = fetch_recent_orders(10);
|
||||
$customerCount = count($catalog);
|
||||
$productOptions = 0;
|
||||
foreach ($catalog as $entry) {
|
||||
$productOptions += count($entry['items']);
|
||||
}
|
||||
render_header('أوامر البيع', 'sales');
|
||||
?>
|
||||
<section class="mb-4">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-4">
|
||||
<div>
|
||||
<span class="section-kicker">Sales workflow</span>
|
||||
<h1 class="page-title mb-2">إنشاء أمر بيع مع تسعير خاص وتحديث مخزون</h1>
|
||||
<p class="page-lead mb-0">اختر العميل أولاً، وستظهر فقط الأصناف المسموح بها له مع السعر الخاص المخزن في النظام. عند التأكيد يتم حفظ الطلب وخصم المخزون مباشرة.</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<span class="badge text-bg-light border text-dark">عملاء مفعّلون: <?= h((string) $customerCount) ?></span>
|
||||
<span class="badge text-bg-light border text-dark">خيارات بيع: <?= h((string) $productOptions) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-4 mb-lg-5">
|
||||
<div class="row g-4 align-items-stretch">
|
||||
<div class="col-lg-7">
|
||||
<div class="card panel-card h-100">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
||||
<div>
|
||||
<span class="section-kicker">Create</span>
|
||||
<h2 class="section-title mb-0">أمر بيع جديد</h2>
|
||||
</div>
|
||||
<span class="text-muted small">الحماية مفعلة: CSRF + prepared statements + output escaping</span>
|
||||
</div>
|
||||
<form method="post" id="sales-order-form" class="row g-3">
|
||||
<input type="hidden" name="csrf_token" value="<?= h(csrf_token()) ?>">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="customer_id">العميل</label>
|
||||
<select class="form-select" id="customer_id" name="customer_id" required>
|
||||
<option value="">اختر العميل</option>
|
||||
<?php foreach ($catalog as $customerId => $entry): ?>
|
||||
<option value="<?= h((string) $customerId) ?>"><?= h($entry['customer_name']) ?> — <?= h($entry['branch_name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="product_id">الصنف المسموح</label>
|
||||
<select class="form-select" id="product_id" name="product_id" required disabled>
|
||||
<option value="">اختر العميل أولاً</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="qty">الكمية</label>
|
||||
<input class="form-control" type="number" min="1" step="1" id="qty" name="qty" value="1" required>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<label class="form-label" for="notes">ملاحظات</label>
|
||||
<input class="form-control" type="text" id="notes" name="notes" maxlength="255" placeholder="ملاحظات داخلية على الطلب أو التسليم">
|
||||
</div>
|
||||
<div class="col-12 d-flex justify-content-between align-items-center flex-wrap gap-2 mt-2">
|
||||
<div class="text-muted small">سيتم إنشاء أمر بيع مؤكد وحساب الربح المتوقع من تكلفة المنتج الحالية.</div>
|
||||
<button class="btn btn-dark" type="submit" id="submit-order-btn">تأكيد أمر البيع</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<div class="card panel-card h-100 summary-card">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<span class="section-kicker">Live summary</span>
|
||||
<h2 class="section-title mb-0">ملخص فوري</h2>
|
||||
</div>
|
||||
<span class="status-badge neutral">Auto pricing</span>
|
||||
</div>
|
||||
<div class="summary-grid">
|
||||
<div class="summary-row"><span>العميل</span><strong id="summary-customer">—</strong></div>
|
||||
<div class="summary-row"><span>الفرع</span><strong id="summary-branch">—</strong></div>
|
||||
<div class="summary-row"><span>السعر الخاص</span><strong id="summary-price">—</strong></div>
|
||||
<div class="summary-row"><span>المتاح بالمخزون</span><strong id="summary-stock">—</strong></div>
|
||||
<div class="summary-row"><span>الربح للوحدة</span><strong id="summary-margin">—</strong></div>
|
||||
<div class="summary-row total"><span>قيمة السطر</span><strong id="summary-total">—</strong></div>
|
||||
</div>
|
||||
<div class="alert alert-light border mt-4 mb-0 small" id="sales-order-hint">
|
||||
اختر عميلاً لعرض الأصناف المسموح بها له. إذا كانت الكمية المطلوبة أكبر من المتاح سيظهر تنبيه ويتم تعطيل الإرسال.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="card panel-card">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
||||
<div>
|
||||
<span class="section-kicker">Recent orders</span>
|
||||
<h2 class="section-title mb-0">قائمة أوامر البيع</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle app-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>الرقم</th>
|
||||
<th>العميل</th>
|
||||
<th>الوقت</th>
|
||||
<th>القيمة</th>
|
||||
<th>الربح المتوقع</th>
|
||||
<th>الإجراء</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (!$recentOrders): ?>
|
||||
<tr>
|
||||
<td colspan="6">
|
||||
<div class="empty-state compact">
|
||||
<div class="empty-title">ابدأ من هنا</div>
|
||||
<div class="empty-copy">أول أمر بيع ستنشئه سيظهر هنا مع تأثيره على المخزون ولوحة التحكم.</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($recentOrders as $order): ?>
|
||||
<tr>
|
||||
<td class="fw-semibold"><?= h($order['order_number']) ?></td>
|
||||
<td>
|
||||
<div><?= h($order['customer_name']) ?></div>
|
||||
<div class="text-muted small"><?= h($order['branch_name']) ?></div>
|
||||
</td>
|
||||
<td><?= h(date('Y-m-d H:i', strtotime($order['created_at']))) ?></td>
|
||||
<td><?= h(format_money((float) $order['subtotal'])) ?></td>
|
||||
<td><?= h(format_money((float) $order['expected_profit'])) ?></td>
|
||||
<td><a class="btn btn-sm btn-outline-secondary" href="order.php?id=<?= h((string) $order['id']) ?>">عرض</a></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<script id="catalog-data" type="application/json"><?= h(json_encode($catalog, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) ?></script>
|
||||
<?php render_footer(); ?>
|
||||
Loading…
x
Reference in New Issue
Block a user