diff --git a/api/customers.php b/api/customers.php index 4d5371f..fe0aa34 100644 --- a/api/customers.php +++ b/api/customers.php @@ -7,6 +7,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $name = trim($_POST['name'] ?? ''); $phoneInput = trim($_POST['phone'] ?? ''); $phone = $phoneInput === '' ? '' : normalize_oman_phone($phoneInput); + $phoneForStorage = $phone === '' ? null : $phone; if (!$name) { echo json_encode(['success' => false, 'error' => tr('الاسم مطلوب', 'Name is required')]); @@ -16,16 +17,24 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { echo json_encode(['success' => false, 'error' => tr('رقم الهاتف يجب أن يكون عمانياً من 8 خانات.', 'Phone must be an 8-digit Oman number.')]); exit; } + if ($phone !== '' && customer_phone_exists($phone)) { + echo json_encode(['success' => false, 'error' => tr('رقم الهاتف هذا مسجل لعميل آخر بالفعل.', 'This phone number is already assigned to another customer.')]); + exit; + } try { $pdo = db(); - $stmt = $pdo->prepare('INSERT INTO customers (name, phone) VALUES (?, ?)'); - $stmt->execute([$name, $phone]); + $stmt = $pdo->prepare('INSERT INTO customers (name, phone, phone_normalized) VALUES (?, ?, ?)'); + $stmt->execute([$name, $phoneForStorage, $phoneForStorage]); $id = $pdo->lastInsertId(); echo json_encode(['success' => true, 'customer' => ['id' => $id, 'name' => $name, 'phone' => $phone]]); } catch (Throwable $e) { - echo json_encode(['success' => false, 'error' => $e->getMessage()]); + if (is_customer_phone_unique_violation($e)) { + echo json_encode(['success' => false, 'error' => tr('رقم الهاتف هذا مسجل لعميل آخر بالفعل.', 'This phone number is already assigned to another customer.')]); + } else { + echo json_encode(['success' => false, 'error' => tr('تعذر حفظ العميل حالياً.', 'Could not save the customer right now.')]); + } } exit; } diff --git a/assets/css/custom.css b/assets/css/custom.css index 10e624c..498793b 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -392,280 +392,3 @@ details summary::-webkit-details-marker { display: none; } } -/* Dashboard-only bright preview */ -body.theme-preview-sunset { - background: - radial-gradient(circle at top left, rgba(255, 122, 0, 0.18), transparent 26%), - radial-gradient(circle at top right, rgba(0, 209, 178, 0.15), transparent 22%), - linear-gradient(180deg, #192132 0%, #111827 45%, #0f1724 100%); -} - -body.theme-preview-sunset #sidebar-wrapper { - background: - linear-gradient(180deg, rgba(255, 122, 0, 0.16), transparent 22%), - linear-gradient(215deg, #1b2433 0%, #111827 100%); - border-right: 1px solid rgba(255, 255, 255, 0.06); -} - -body.theme-preview-sunset .sidebar-heading { - background: rgba(255, 255, 255, 0.03); - border-bottom: 1px solid rgba(255,255,255,0.08); - color: #f9fafb; -} - -body.theme-preview-sunset #sidebar-wrapper .text-white, -body.theme-preview-sunset #sidebar-wrapper .text-white-50, -body.theme-preview-sunset #sidebar-wrapper .sidebar-heading span, -body.theme-preview-sunset #sidebar-wrapper .fw-semibold { - color: #f9fafb !important; -} - -body.theme-preview-sunset #sidebar-wrapper .list-group-item { - color: rgba(249, 250, 251, 0.72); -} - -body.theme-preview-sunset #sidebar-wrapper .list-group-item:hover, -body.theme-preview-sunset #sidebar-wrapper .list-group-item.active { - background: linear-gradient(135deg, rgba(255, 122, 0, 0.18), rgba(0, 209, 178, 0.14)); - color: #ffffff; - box-shadow: 0 12px 26px rgba(0, 0, 0, 0.16); -} - -body.theme-preview-sunset #sidebar-wrapper .list-group-item:hover i, -body.theme-preview-sunset #sidebar-wrapper .list-group-item.active i { - color: #ffd166; -} - -body.theme-preview-sunset .top-navbar { - background: rgba(17, 24, 39, 0.82); - backdrop-filter: blur(16px); - -webkit-backdrop-filter: blur(16px); - border-bottom: 1px solid rgba(255, 255, 255, 0.08); - box-shadow: 0 18px 40px rgba(0, 0, 0, 0.18); -} - -body.theme-preview-sunset .top-navbar h4, -body.theme-preview-sunset .top-navbar .dropdown-toggle, -body.theme-preview-sunset .top-navbar .btn { - color: #f9fafb; -} - -body.theme-preview-sunset .top-navbar .btn-outline-success, -body.theme-preview-sunset .top-navbar .btn-outline-primary, -body.theme-preview-sunset .top-navbar .btn-light { - border-color: rgba(255, 255, 255, 0.12); -} - -body.theme-preview-sunset .dashboard-sunset-sample { - position: relative; - z-index: 1; -} - -body.theme-preview-sunset .dashboard-hero { - position: relative; - background: - radial-gradient(circle at top right, rgba(255, 122, 0, 0.28), transparent 30%), - radial-gradient(circle at bottom left, rgba(0, 209, 178, 0.2), transparent 28%), - linear-gradient(135deg, rgba(27, 36, 51, 0.98) 0%, rgba(17, 24, 39, 0.98) 100%); - color: #f9fafb; - border: 1px solid rgba(255, 255, 255, 0.08) !important; - box-shadow: 0 28px 70px rgba(0, 0, 0, 0.28); -} - -body.theme-preview-sunset .dashboard-hero::before, -body.theme-preview-sunset .dashboard-hero::after { - content: ''; - position: absolute; - border-radius: 999px; - pointer-events: none; -} - -body.theme-preview-sunset .dashboard-hero::before { - width: 220px; - height: 220px; - right: -50px; - top: -50px; - background: radial-gradient(circle, rgba(255, 122, 0, 0.34), rgba(255, 122, 0, 0)); -} - -body.theme-preview-sunset .dashboard-hero::after { - width: 240px; - height: 240px; - left: -70px; - bottom: -80px; - background: radial-gradient(circle, rgba(0, 209, 178, 0.22), rgba(0, 209, 178, 0)); -} - -body.theme-preview-sunset .dashboard-kicker { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.55rem 0.9rem; - border-radius: 999px; - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 209, 102, 0.22); - color: #ffd166; - font-size: 0.85rem; - letter-spacing: 0.04em; - text-transform: uppercase; -} - -body.theme-preview-sunset .dashboard-hero-title { - color: #f9fafb; - font-size: clamp(2rem, 4vw, 3.2rem); - line-height: 1.05; - max-width: 13ch; -} - -body.theme-preview-sunset .dashboard-hero-copy { - color: rgba(249, 250, 251, 0.74); - font-size: 1rem; - max-width: 64ch; -} - -body.theme-preview-sunset .dashboard-hero-panel { - padding: 1.25rem; - border-radius: 1.5rem; - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(255, 255, 255, 0.08); - box-shadow: inset 0 1px 0 rgba(255,255,255,0.05), 0 18px 40px rgba(0, 0, 0, 0.16); -} - -body.theme-preview-sunset .dashboard-chip-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0.8rem; -} - -body.theme-preview-sunset .dashboard-chip { - display: inline-flex; - justify-content: center; - padding: 0.75rem 0.9rem; - border-radius: 1rem; - font-weight: 700; - letter-spacing: 0.02em; - color: #111827; -} - -body.theme-preview-sunset .dashboard-chip--orange { background: #ffb36b; } -body.theme-preview-sunset .dashboard-chip--teal { background: #7ce9d7; } -body.theme-preview-sunset .dashboard-chip--gold { background: #ffd166; } -body.theme-preview-sunset .dashboard-chip--rose { background: #ff9aae; } - -body.theme-preview-sunset .dashboard-hero-meta { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0.9rem; -} - -body.theme-preview-sunset .dashboard-hero-meta small { - display: block; - color: rgba(249, 250, 251, 0.58); - margin-bottom: 0.25rem; -} - -body.theme-preview-sunset .dashboard-hero-meta strong { - color: #f9fafb; - font-size: 1rem; -} - -body.theme-preview-sunset .dashboard-sunset-sample .card { - border: 1px solid rgba(255, 255, 255, 0.08); - background: rgba(27, 36, 51, 0.94); - box-shadow: 0 18px 40px rgba(0, 0, 0, 0.18); -} - -body.theme-preview-sunset .dashboard-sunset-sample .card-header, -body.theme-preview-sunset .dashboard-sunset-sample .card-body, -body.theme-preview-sunset .dashboard-sunset-sample .table td, -body.theme-preview-sunset .dashboard-sunset-sample .table th, -body.theme-preview-sunset .dashboard-sunset-sample .text-muted, -body.theme-preview-sunset .dashboard-sunset-sample .small { - color: rgba(249, 250, 251, 0.72) !important; -} - -body.theme-preview-sunset .dashboard-sunset-sample .card-header { - border-bottom: 1px solid rgba(255, 255, 255, 0.08); -} - -body.theme-preview-sunset .dashboard-sunset-sample .table th { - background: rgba(255, 255, 255, 0.04); - color: rgba(249, 250, 251, 0.64) !important; - border-bottom-color: rgba(255, 255, 255, 0.08); -} - -body.theme-preview-sunset .dashboard-sunset-sample .table td { - border-bottom-color: rgba(255, 255, 255, 0.06); -} - -body.theme-preview-sunset .dashboard-sunset-sample .table-hover tbody tr:hover { - background: rgba(255, 255, 255, 0.03); -} - -body.theme-preview-sunset .dashboard-sunset-sample .dashboard-stat-card { - overflow: hidden; - position: relative; - color: #ffffff; -} - -body.theme-preview-sunset .dashboard-sunset-sample .dashboard-stat-card::before { - content: ''; - position: absolute; - inset: 0; - background: linear-gradient(135deg, rgba(255,255,255,0.18), rgba(255,255,255,0.03)); - opacity: 0.92; -} - -body.theme-preview-sunset .dashboard-sunset-sample .dashboard-stat-card > .card-body { - position: relative; - z-index: 1; -} - -body.theme-preview-sunset .dashboard-sunset-sample .dashboard-stat-card--cyan { - background: linear-gradient(135deg, #ff7a00 0%, #ff9d42 100%); -} - -body.theme-preview-sunset .dashboard-sunset-sample .dashboard-stat-card--coral { - background: linear-gradient(135deg, #00d1b2 0%, #1ee4c8 100%); -} - -body.theme-preview-sunset .dashboard-sunset-sample .dashboard-stat-card--lime { - background: linear-gradient(135deg, #ffd166 0%, #ffdf95 100%); - color: #1b2433; -} - -body.theme-preview-sunset .dashboard-sunset-sample .dashboard-stat-card--gold { - background: linear-gradient(135deg, #ff4d6d 0%, #ff7f95 100%); -} - -body.theme-preview-sunset .dashboard-sunset-sample .display-6, -body.theme-preview-sunset .dashboard-sunset-sample h5, -body.theme-preview-sunset .dashboard-sunset-sample h6 { - color: inherit; -} - -body.theme-preview-sunset .dashboard-sunset-sample .btn-primary { - background: linear-gradient(135deg, #ff7a00 0%, #ff9440 100%); - border: none; - box-shadow: 0 14px 28px rgba(255, 122, 0, 0.22); -} - -body.theme-preview-sunset .dashboard-sunset-sample .btn-light { - background: rgba(255,255,255,0.08); - border-color: rgba(255,255,255,0.12); - color: #f9fafb; -} - -body.theme-preview-sunset .dashboard-sunset-sample .badge { - border-radius: 999px; -} - -@media (max-width: 991.98px) { - body.theme-preview-sunset { - background: linear-gradient(180deg, #1b2433 0%, #111827 60%, #0f1724 100%); - } - - body.theme-preview-sunset .dashboard-hero-title { - max-width: none; - } -} diff --git a/customers.php b/customers.php index 8034137..89fd04a 100644 --- a/customers.php +++ b/customers.php @@ -13,26 +13,54 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($action === 'create') { $phoneInput = trim((string) ($_POST['phone'] ?? '')); $phone = $phoneInput === '' ? '' : normalize_oman_phone($phoneInput); + $phoneForStorage = $phone === '' ? null : $phone; if ($phoneInput !== '' && $phone === '') { set_flash('danger', tr('رقم الهاتف يجب أن يكون عمانياً من 8 خانات.', 'Phone must be an 8-digit Oman number.')); redirect_to('customers.php'); } + if ($phone !== '' && customer_phone_exists($phone)) { + set_flash('danger', tr('رقم الهاتف هذا مسجل لعميل آخر بالفعل.', 'This phone number is already assigned to another customer.')); + redirect_to('customers.php'); + } - $stmt = $pdo->prepare('INSERT INTO customers (name, phone, email, address) VALUES (?, ?, ?, ?)'); - $stmt->execute([$_POST['name'], $phone, $_POST['email'] ?? '', $_POST['address'] ?? '']); - set_flash('success', tr('تمت إضافة العميل بنجاح', 'Customer added successfully')); + try { + $stmt = $pdo->prepare('INSERT INTO customers (name, phone, phone_normalized, email, address) VALUES (?, ?, ?, ?, ?)'); + $stmt->execute([$_POST['name'], $phoneForStorage, $phoneForStorage, $_POST['email'] ?? '', $_POST['address'] ?? '']); + set_flash('success', tr('تمت إضافة العميل بنجاح', 'Customer added successfully')); + } catch (Throwable $e) { + if (is_customer_phone_unique_violation($e)) { + set_flash('danger', tr('رقم الهاتف هذا مسجل لعميل آخر بالفعل.', 'This phone number is already assigned to another customer.')); + } else { + throw $e; + } + } redirect_to('customers.php'); } elseif ($action === 'edit') { $phoneInput = trim((string) ($_POST['phone'] ?? '')); $phone = $phoneInput === '' ? '' : normalize_oman_phone($phoneInput); + $phoneForStorage = $phone === '' ? null : $phone; if ($phoneInput !== '' && $phone === '') { set_flash('danger', tr('رقم الهاتف يجب أن يكون عمانياً من 8 خانات.', 'Phone must be an 8-digit Oman number.')); redirect_to('customers.php'); } - $stmt = $pdo->prepare('UPDATE customers SET name = ?, phone = ?, email = ?, address = ? WHERE id = ?'); - $stmt->execute([$_POST['name'], $phone, $_POST['email'] ?? '', $_POST['address'] ?? '', $_POST['id']]); - set_flash('success', tr('تم التحديث بنجاح', 'Updated successfully')); + $customerId = (int) ($_POST['id'] ?? 0); + if ($phone !== '' && customer_phone_exists($phone, $customerId)) { + set_flash('danger', tr('رقم الهاتف هذا مسجل لعميل آخر بالفعل.', 'This phone number is already assigned to another customer.')); + redirect_to('customers.php'); + } + + try { + $stmt = $pdo->prepare('UPDATE customers SET name = ?, phone = ?, phone_normalized = ?, email = ?, address = ? WHERE id = ?'); + $stmt->execute([$_POST['name'], $phoneForStorage, $phoneForStorage, $_POST['email'] ?? '', $_POST['address'] ?? '', $_POST['id']]); + set_flash('success', tr('تم التحديث بنجاح', 'Updated successfully')); + } catch (Throwable $e) { + if (is_customer_phone_unique_violation($e)) { + set_flash('danger', tr('رقم الهاتف هذا مسجل لعميل آخر بالفعل.', 'This phone number is already assigned to another customer.')); + } else { + throw $e; + } + } redirect_to('customers.php'); } elseif ($action === 'delete') { $stmt = $pdo->prepare('DELETE FROM customers WHERE id = ?'); diff --git a/db/migrations/2026-04-26_customers_phone_normalized_unique.sql b/db/migrations/2026-04-26_customers_phone_normalized_unique.sql new file mode 100644 index 0000000..9abb524 --- /dev/null +++ b/db/migrations/2026-04-26_customers_phone_normalized_unique.sql @@ -0,0 +1,54 @@ +-- Add normalized customer phone storage and a unique index for duplicate protection. +-- Safe to import on an existing database before/after the app code change. + +DROP PROCEDURE IF EXISTS apply_customers_phone_normalized_unique; +DELIMITER $$ +CREATE PROCEDURE apply_customers_phone_normalized_unique() +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'customers' + AND COLUMN_NAME = 'phone_normalized' + ) THEN + ALTER TABLE customers + ADD COLUMN phone_normalized VARCHAR(8) DEFAULT NULL AFTER phone; + END IF; + + UPDATE customers + SET phone = NULLIF(TRIM(phone), ''); + + UPDATE customers + SET phone_normalized = CASE + WHEN phone IS NULL OR TRIM(phone) = '' THEN NULL + WHEN REPLACE(REPLACE(REPLACE(REPLACE(phone, ' ', ''), '-', ''), '(', ''), ')', '') REGEXP '^[0-9]{8}$' + THEN REPLACE(REPLACE(REPLACE(REPLACE(phone, ' ', ''), '-', ''), '(', ''), ')', '') + WHEN REPLACE(REPLACE(REPLACE(REPLACE(phone, ' ', ''), '-', ''), '(', ''), ')', '') REGEXP '^0[0-9]{8}$' + THEN RIGHT(REPLACE(REPLACE(REPLACE(REPLACE(phone, ' ', ''), '-', ''), '(', ''), ')', ''), 8) + WHEN REPLACE(REPLACE(REPLACE(REPLACE(phone, ' ', ''), '-', ''), '(', ''), ')', '') REGEXP '^968[0-9]{8}$' + THEN RIGHT(REPLACE(REPLACE(REPLACE(REPLACE(phone, ' ', ''), '-', ''), '(', ''), ')', ''), 8) + WHEN REPLACE(REPLACE(REPLACE(REPLACE(phone, ' ', ''), '-', ''), '(', ''), ')', '') REGEXP '^00968[0-9]{8}$' + THEN RIGHT(REPLACE(REPLACE(REPLACE(REPLACE(phone, ' ', ''), '-', ''), '(', ''), ')', ''), 8) + ELSE NULL + END; + + IF NOT EXISTS ( + SELECT 1 + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'customers' + AND INDEX_NAME = 'uniq_customers_phone_normalized' + ) THEN + ALTER TABLE customers + ADD UNIQUE KEY uniq_customers_phone_normalized (phone_normalized); + END IF; +END $$ +DELIMITER ; + +CALL apply_customers_phone_normalized_unique(); +DROP PROCEDURE IF EXISTS apply_customers_phone_normalized_unique; + +-- Optional verification after import: +-- SHOW COLUMNS FROM customers; +-- SHOW INDEX FROM customers; diff --git a/db/schema.sql b/db/schema.sql index ccfb4a6..103c871 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -29,10 +29,12 @@ CREATE TABLE IF NOT EXISTS `customers` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `name` varchar(150) NOT NULL, `phone` varchar(50) DEFAULT NULL, + `phone_normalized` varchar(8) DEFAULT NULL, `email` varchar(150) DEFAULT NULL, `address` text DEFAULT NULL, `created_at` datetime NOT NULL DEFAULT current_timestamp(), - PRIMARY KEY (`id`) + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_customers_phone_normalized` (`phone_normalized`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- Table structure for `expense_categories` diff --git a/edit_sale.php b/edit_sale.php index e17b04e..60a3a6e 100644 --- a/edit_sale.php +++ b/edit_sale.php @@ -628,8 +628,13 @@ async function saveNewCustomer() { custInput.value = data.customer.name + (data.customer.phone ? ' - ' + data.customer.phone : ''); document.getElementById('formCustomerId').value = data.customer.id; newCustomerModalObj.hide(); - const Toast = Swal.mixin({ toast: true, position: 'top-end', showConfirmButton: false, timer: 2000 }); - Toast.fire({ icon: 'success', title: '= h(tr('تم إضافة العميل', 'Customer added')) ?>' }); + Swal.fire({ + icon: 'success', + text: '= h(tr('تم إضافة العميل', 'Customer added')) ?>', + position: 'center', + confirmButtonText: '= h(tr('حسناً', 'OK')) ?>', + confirmButtonColor: '#0d6efd' + }); } else { Swal.fire({icon: 'warning', text: data.error}); } diff --git a/includes/app.php b/includes/app.php index d42eb50..b343dc7 100644 --- a/includes/app.php +++ b/includes/app.php @@ -159,6 +159,55 @@ try { @file_put_contents($flagFileV8, '1'); } + + $flagFileV9 = sys_get_temp_dir() . '/.schema_migrated_v9_' . md5(__DIR__); + if (!file_exists($flagFileV9)) { + $pdo = db(); + $hasCustomersTable = (bool) $pdo->query("SHOW TABLES LIKE 'customers'")->fetchColumn(); + if ($hasCustomersTable) { + $hasPhoneNormalizedColumn = (bool) $pdo->query("SHOW COLUMNS FROM customers LIKE 'phone_normalized'")->fetchColumn(); + if (!$hasPhoneNormalizedColumn) { + $pdo->exec("ALTER TABLE customers ADD COLUMN phone_normalized varchar(8) DEFAULT NULL AFTER phone"); + } + + $customerRows = $pdo->query("SELECT id, phone FROM customers ORDER BY id ASC")->fetchAll(PDO::FETCH_ASSOC); + $updateCustomerPhoneStmt = $pdo->prepare("UPDATE customers SET phone = :phone, phone_normalized = :phone_normalized WHERE id = :id"); + $seenNormalizedPhones = []; + $duplicateNormalizedPhones = []; + + foreach ($customerRows as $customerRow) { + $rawPhone = trim((string) ($customerRow['phone'] ?? '')); + $normalizedPhone = normalize_oman_phone($rawPhone); + $phoneForStorage = $normalizedPhone !== '' ? $normalizedPhone : ($rawPhone !== '' ? $rawPhone : null); + $phoneNormalizedForStorage = $normalizedPhone !== '' ? $normalizedPhone : null; + $customerId = (int) ($customerRow['id'] ?? 0); + + if ($phoneNormalizedForStorage !== null) { + if (isset($seenNormalizedPhones[$phoneNormalizedForStorage]) && $seenNormalizedPhones[$phoneNormalizedForStorage] !== $customerId) { + $duplicateNormalizedPhones[$phoneNormalizedForStorage] = true; + } else { + $seenNormalizedPhones[$phoneNormalizedForStorage] = $customerId; + } + } + + $updateCustomerPhoneStmt->bindValue(':phone', $phoneForStorage, $phoneForStorage === null ? PDO::PARAM_NULL : PDO::PARAM_STR); + $updateCustomerPhoneStmt->bindValue(':phone_normalized', $phoneNormalizedForStorage, $phoneNormalizedForStorage === null ? PDO::PARAM_NULL : PDO::PARAM_STR); + $updateCustomerPhoneStmt->bindValue(':id', $customerId, PDO::PARAM_INT); + $updateCustomerPhoneStmt->execute(); + } + + $hasPhoneNormalizedIndex = (bool) $pdo->query("SHOW INDEX FROM customers WHERE Key_name = 'uniq_customers_phone_normalized'")->fetchColumn(); + if (!$hasPhoneNormalizedIndex) { + if ($duplicateNormalizedPhones === []) { + $pdo->exec("ALTER TABLE customers ADD UNIQUE KEY uniq_customers_phone_normalized (phone_normalized)"); + } else { + error_log('Skipped adding uniq_customers_phone_normalized because duplicate normalized customer phones still exist: ' . implode(', ', array_keys($duplicateNormalizedPhones))); + } + } + } + + @file_put_contents($flagFileV9, '1'); + } } catch (\Throwable $e) {} @@ -298,6 +347,59 @@ function phone_display(?string $value): string return $local !== '' ? $local : $raw; } +function customer_phone_exists(string $phone, ?int $excludeCustomerId = null): bool +{ + $normalized = normalize_oman_phone($phone); + if ($normalized === '') { + return false; + } + + $variants = array_values(array_unique([ + $normalized, + '968' . $normalized, + '00968' . $normalized, + '0' . $normalized, + ])); + + $placeholders = []; + foreach ($variants as $index => $_variant) { + $placeholders[] = ':phone_' . $index; + } + + $sql = 'SELECT id FROM customers WHERE phone IN (' . implode(', ', $placeholders) . ')'; + if ($excludeCustomerId !== null && $excludeCustomerId > 0) { + $sql .= ' AND id <> :exclude_customer_id'; + } + $sql .= ' LIMIT 1'; + + try { + $stmt = db()->prepare($sql); + foreach ($variants as $index => $variant) { + $stmt->bindValue(':phone_' . $index, $variant); + } + if ($excludeCustomerId !== null && $excludeCustomerId > 0) { + $stmt->bindValue(':exclude_customer_id', $excludeCustomerId, PDO::PARAM_INT); + } + $stmt->execute(); + return (bool) $stmt->fetchColumn(); + } catch (Throwable $e) { + return false; + } +} + +function is_customer_phone_unique_violation(Throwable $e): bool +{ + $message = strtolower($e->getMessage()); + $code = (string) $e->getCode(); + + if (!in_array($code, ['23000', '1062'], true) && !str_contains($message, 'duplicate entry')) { + return false; + } + + return str_contains($message, 'uniq_customers_phone_normalized') + || str_contains($message, 'phone_normalized'); +} + function wablas_parse_phone_list(string $value): array { $parts = preg_split('/[\s,;،]+/', trim($value)) ?: []; diff --git a/includes/header.php b/includes/header.php index 2a1c9c4..2c32c38 100644 --- a/includes/header.php +++ b/includes/header.php @@ -12,7 +12,7 @@ $assetVersion = date('YmdHi'); // Determine if we are on a public page (like login) $isPublic = !empty($forcePublic) || !isset($user) || !$user; -$bodyClasses = trim(($isPublic ? 'auth-body ' : '') . ($bodyClass ?? '')); +$bodyClasses = $isPublic ? 'auth-body' : ''; ?> @@ -268,12 +268,10 @@ $bodyClasses = trim(($isPublic ? 'auth-body ' : '') . ($bodyClass ?? '')); document.addEventListener('DOMContentLoaded', function() { Swal.fire({ icon: '= h($flash['type']) === "danger" ? "error" : (h($flash['type']) === "warning" ? "warning" : "success") ?>', - title: '= h($flash['message']) ?>', - toast: true, - position: 'top-end', - showConfirmButton: false, - timer: 3000, - timerProgressBar: true + text: '= h($flash['message']) ?>', + position: 'center', + confirmButtonText: '= h(tr("حسناً", "OK")) ?>', + confirmButtonColor: '#0d6efd' }); }); diff --git a/includes/sale_form.php b/includes/sale_form.php index dad35d1..95830b6 100644 --- a/includes/sale_form.php +++ b/includes/sale_form.php @@ -635,8 +635,13 @@ async function saveNewCustomer() { custInput.value = data.customer.name + (data.customer.phone ? ' - 968 ' + data.customer.phone : ''); document.getElementById('formCustomerId').value = data.customer.id; newCustomerModalObj.hide(); - const Toast = Swal.mixin({ toast: true, position: 'top-end', showConfirmButton: false, timer: 2000 }); - Toast.fire({ icon: 'success', title: '= h(tr('تم إضافة العميل', 'Customer added')) ?>' }); + Swal.fire({ + icon: 'success', + text: '= h(tr('تم إضافة العميل', 'Customer added')) ?>', + position: 'center', + confirmButtonText: '= h(tr('حسناً', 'OK')) ?>', + confirmButtonColor: '#0d6efd' + }); } else { Swal.fire({icon: 'warning', text: data.error}); } diff --git a/index.php b/index.php index 47d9439..ab26d26 100644 --- a/index.php +++ b/index.php @@ -3,7 +3,6 @@ require_once __DIR__ . '/includes/app.php'; $user = require_auth(); $pageTitle = tr('لوحة التحكم', 'Dashboard'); $activeNav = 'dashboard'; -$bodyClass = 'theme-preview-sunset'; $dbError = null; $metrics = ['today_sales' => 0, 'today_revenue' => 0.0, 'pos_count' => 0, 'normal_count' => 0, 'recent' => []]; $reportMetrics = ['branch_totals' => [], 'payment_totals' => [], 'product_totals' => []]; @@ -49,46 +48,10 @@ require __DIR__ . '/includes/header.php';
= h(tr('هذه ما تزال مجرد معاينة على لوحة التحكم فقط، ولكن الآن باتجاه Sunset Contrast بدرجات برتقالية ودافئة مع لمسات تركواز وذهبية. إذا أعجبك الشكل سأطبّق نفس الهوية على الشريط الجانبي والجداول والنماذج وباقي الصفحات.', 'This is still a dashboard-only preview, but now in the Sunset Contrast direction with warm orange, teal, gold, and rose accents. If you like it, I can extend the same identity to the sidebar, tables, forms, and the rest of the app.')) ?>
-