/* ========================================================================== Siro Admin Console — application engine All figures rendered here come from the production API. There is no mock/demo dataset: when a request fails the table says so instead of showing invented numbers. ========================================================================== */ (() => { 'use strict'; // Bump together with the ?v= query in index.html. Shown in the UI and in the // diagnostics report so "the deploy did nothing" can be answered with a fact // rather than a guess about caching. const BUILD = '2026-07-25-10'; // ── Localisation ───────────────────────────────────────────────────────── // Arabic is the operators' language; English is kept because several screens // mirror field names that only exist in English in the database. const LANG_KEY = 'siro_admin_lang'; let lang = localStorage.getItem(LANG_KEY) || 'ar'; const AR = { // Auth 'Operations & Control Console': 'منصة العمليات والتحكم', 'Secure session · device-bound token · WhatsApp OTP': 'جلسة آمنة · توكن مرتبط بالجهاز · رمز واتساب', 'Admin Email / Phone': 'البريد الإلكتروني أو رقم الهاتف', 'Password': 'كلمة المرور', 'Sign In': 'تسجيل الدخول', 'Verifying…': 'جارٍ التحقق…', 'WhatsApp Verification': 'التحقق عبر واتساب', 'Verify & Sign In': 'تحقّق وسجّل الدخول', 'Cancel': 'إلغاء', 'Enter the 3-digit code.': 'أدخل الرمز المكوّن من ٣ أرقام.', // Nav 'Operations': 'العمليات', 'Overview': 'نظرة عامة', 'Rides': 'الرحلات', 'Captains': 'الكباتن', 'Passengers': 'الركاب', 'Business': 'الأعمال', 'Financials': 'المالية', 'Approvals': 'الموافقات', 'Complaints': 'الشكاوى', 'Session & Security': 'الجلسة والأمان', 'Realtime & Analytics': 'الوقت الحقيقي والتحليلات', 'Live Operations': 'العمليات الحيّة', 'Growth': 'النمو', 'Advanced Analytics': 'التحليلات المتقدمة', 'Ride Monitor': 'مراقب الرحلات', 'Finance': 'المالية', 'Financial V2': 'المالية V2', 'Invoices': 'الفواتير', 'Growth & Pricing': 'النمو والتسعير', 'Marketing Intelligence': 'استخبارات التسويق', 'Pricing Engine': 'محرك التسعير', 'Tariff Editor': 'محرر التعرفة', 'Promo Codes': 'أكواد الخصم', 'Campaign Launcher': 'مطلق الحملات', 'Demand Heatmap': 'خريطة الطلب', 'Quality': 'الجودة', 'Fleet Performance': 'أداء الأسطول', 'Blacklist & Removal': 'القائمة السوداء والحذف', 'Driver Scorecard': 'تقييم الكباتن', 'Driver Documents': 'وثائق السائقين', 'Transit': 'المواصلات', 'Mawasalati Organisations': 'مؤسسات مواصلاتي', 'Route Approvals': 'اعتماد المسارات', 'Administration': 'الإدارة', 'Staff & Employees': 'الكوادر والموظفون', 'App Versions': 'إصدارات التطبيق', 'Broadcast Notification': 'إشعار جماعي', 'Audit Log': 'سجل العمليات', 'Error Log': 'سجل الأخطاء', // Chrome 'Filter the current table…': 'ابحث في الجدول الحالي…', 'Live database': 'قاعدة بيانات حيّة', 'Loading live data…': 'جارٍ تحميل البيانات…', 'Connecting…': 'جارٍ الاتصال…', 'No data from API': 'لا توجد بيانات من الخادم', 'Data unavailable': 'البيانات غير متاحة', 'Sign Out': 'تسجيل الخروج', 'Refresh data': 'تحديث البيانات', 'Reload': 'إعادة تحميل', // Common 'Loading…': 'جارٍ التحميل…', 'Name': 'الاسم', 'Phone': 'الهاتف', 'Email': 'البريد الإلكتروني', 'Status': 'الحالة', 'Rating': 'التقييم', 'Trips': 'الرحلات', 'Cancellations': 'الإلغاءات', 'Joined': 'تاريخ الانضمام', 'Action': 'إجراء', 'Show more': 'عرض المزيد', 'Approve': 'اعتماد', 'Reject': 'رفض', 'Activate': 'تفعيل', 'Save': 'حفظ', 'Nothing changed on this tariff row.': 'لم يتغيّر شيء في هذا السطر.', // Ride statuses 'Completed': 'مكتملة', 'In progress': 'جارية', 'Waiting': 'بانتظار كابتن', 'Cancelled': 'ملغاة', 'Cancelled by captain': 'ألغاها الكابتن', 'Cancelled by passenger': 'ألغاها الراكب', 'Captain assigned': 'تم تعيين كابتن', 'Captain arrived': 'وصل الكابتن', 'No captain found': 'لم يُعثر على كابتن', 'Timed out': 'انتهت المهلة', 'Searching for a captain': 'جارٍ البحث عن كابتن', 'Pending review': 'قيد المراجعة', 'Refused': 'مرفوضة', 'Unknown': 'غير معروف', 'The server accepted the request but issued no code and no token.': 'قبل الخادم الطلب لكنه لم يُصدر رمزاً ولا توكن.', 'last 21 days': 'آخر ٢١ يوماً', 'Updated': 'آخر تحديث', 'Morning 6–11': 'صباحاً ٦–١١', 'Afternoon 12–17': 'ظهراً ١٢–١٧', 'Night 18–5': 'ليلاً ١٨–٥', 'Comfort': 'كومفورت', 'Speed': 'سبيد', 'Lady': 'ليدي', 'Average trip duration': 'متوسط مدة الرحلة', 'Longest trip duration': 'أطول مدة رحلة', 'Average distance': 'متوسط المسافة', 'Longest distance': 'أطول مسافة', 'Cancelled rides': 'الرحلات الملغاة', 'Captains with completed trips': 'كباتن أتمّوا رحلات', 'Operations Overview': 'نظرة عامة على العمليات', 'Live figures read directly from the production database': 'أرقام حيّة مقروءة مباشرة من قاعدة بيانات الإنتاج', 'Total Rides': 'إجمالي الرحلات', 'Completed Rides': 'الرحلات المكتملة', 'Registered Captains': 'الكباتن المسجّلون', 'Registered Passengers': 'الركاب المسجّلون', 'this month': 'هذا الشهر', 'completion rate': 'نسبة الإتمام', 'joined this month': 'انضموا هذا الشهر', 'Rides per Day': 'الرحلات يومياً', 'Ride Outcomes': 'نتائج الرحلات', 'Demand by Time of Day': 'الطلب حسب وقت اليوم', 'Service Mix': 'توزيع الخدمات', 'Trips, assigned captains, fares and live status': 'الرحلات والكباتن المعيّنون والأجور والحالة الحيّة', 'Fleet roster, ratings and trip volume': 'قائمة الأسطول والتقييمات وعدد الرحلات', 'Most active customer accounts by trip count': 'أكثر حسابات العملاء نشاطاً حسب عدد الرحلات', 'Earnings, fares and distance aggregates across all recorded trips': 'الأرباح والأجور والمسافات لكل الرحلات المسجّلة', 'Admin and service accounts awaiting activation': 'حسابات المشرفين والخدمة بانتظار التفعيل', 'Volume of customer complaints filed': 'عدد الشكاوى المقدَّمة', 'Details of the credential currently authorising this console': 'تفاصيل الاعتماد الذي يخوّل هذه المنصة حالياً', 'Trip': 'الرحلة', 'Passenger': 'الراكب', 'Captain': 'الكابتن', 'Route': 'المسار', 'Fare': 'الأجرة', 'Created': 'أُنشئت', 'ID': 'المعرّف', 'Customer ID': 'معرّف العميل', 'Driver ID': 'معرّف السائق', 'Requested role': 'الدور المطلوب', 'Requested at': 'تاريخ الطلب', 'Type': 'النوع', 'Filed today': 'قُدّمت اليوم', 'This week': 'هذا الأسبوع', 'This month': 'هذا الشهر', 'Current session': 'الجلسة الحالية', 'Server diagnostics': 'تشخيص الخادم', 'API endpoint': 'عنوان الـ API', 'Compose': 'إنشاء الرسالة', 'Preview': 'معاينة', 'Pending Approvals': 'الموافقات المعلّقة', 'Captain earnings (completed trips)': 'أرباح الكباتن (الرحلات المكتملة)', 'Average passenger fare': 'متوسط أجرة الراكب', 'Total distance covered': 'إجمالي المسافة المقطوعة', 'Trip Performance': 'أداء الرحلات', 'Pick a captain above to inspect their documents.': 'اختر كابتناً بالأعلى لفحص وثائقه.', // Module groups 'Realtime & Analytics': 'الوقت الحقيقي والتحليلات', 'Growth & Pricing': 'النمو والتسعير', // Module titles 'Live Operations': 'العمليات الحيّة', 'Realtime fleet counters and the alerts that need attention now': 'عدادات الأسطول الحيّة والتنبيهات التي تحتاج اهتماماً الآن', 'Growth': 'النمو', 'How many passengers and captains joined each day over the last 30 days': 'عدد الركاب والكباتن المنضمين يومياً خلال آخر 30 يوماً', 'Advanced Analytics': 'التحليلات المتقدمة', 'Revenue, ranking and dashboard aggregates from the v2 engine': 'الإيرادات والتصنيف والملخصات من المحرك V2', 'Ride Monitor': 'مراقب الرحلات', 'Look up the active ride and gift eligibility for a specific phone number': 'البحث عن الرحلة النشطة وأهلية الهدايا لرقم هاتف محدد', 'Finance': 'المالية', 'Financial V2': 'المالية V2', 'Settlement runs and financial aggregates': 'عمليات التسوية والملخصات المالية', 'Invoices': 'الفواتير', 'Invoice totals recorded against admin accounts': 'إجمالي الفواتير المسجّلة لحسابات المشرفين', 'Tariff Editor': 'محرر التعرفة', 'The live Kazan tariff — every change here alters what passengers pay': 'تعرفة كازان الحيّة — كل تغيير هنا يغيّر ما يدفعه الركاب', 'Promo Codes': 'أكواد الخصم', 'Active discount codes': 'أكواد الخصم النشطة', 'Demand Heatmap': 'خريطة الطلب', 'Geofenced demand density': 'كثافة الطلب حسب المنطقة', 'Fleet Performance': 'أداء الأسطول', 'Top captains, gift eligibility payouts and per-captain card charges': 'أفضل الكباتن وأحقيّة الهدايا ورسوم البطاقة لكل كابتن', 'Blacklist & Removal': 'القائمة السوداء والحذف', 'Blocked accounts, permanent removals and lifting a block': 'الحسابات المحظورة والحذف الدائم ورفع الحظر', 'Driver Scorecard': 'تقييم الكباتن', 'Behaviour and reliability scoring per captain': 'تقييم السلوك والموثوقية لكل كابتن', 'Driver Documents': 'وثائق السائقين', 'Captains awaiting document review and activation': 'كباتن بانتظار مراجعة الوثائق والتفعيل', 'Campaign Launcher': 'مطلق الحملات', 'Generate an AI pricing campaign, preview it, then dispatch': 'إنشاء حملة تسعير بالذكاء الاصطناعي ومعاينتها ثم إرسالها', 'App Versions': 'إصدارات التطبيق', 'The version each Siro app checks itself against': 'الإصدار الذي يتحقق منه كل تطبيق سير', 'Broadcast Notification': 'إشعار جماعي', 'Push a notification to every captain or every passenger': 'إرسال إشعار لكل كابتن أو كل راكب', 'Staff & Employees': 'الكوادر والموظفون', 'Internal staff records, activation and onboarding': 'سجلات الموظفين والتفعيل والتعيين', 'Audit Log': 'سجل العمليات', 'Privileged actions recorded across the platform': 'الإجراءات المميّزة المسجّلة عبر المنصة', 'Error Log': 'سجل الأخطاء', 'Last errors reported by the mobile apps': 'آخر الأخطاء المبلّغ عنها من التطبيقات', // Reload button 'Reload': 'إعادة تحميل', // Module panel messages 'Loading…': 'جارٍ التحميل…', 'Loading growth…': 'جارٍ تحميل بيانات النمو…', 'Loading revenue…': 'جارٍ تحميل الإيرادات…', 'Loading captain ranking…': 'جارٍ تحميل تصنيف الكباتن…', 'Loading organisations…': 'جارٍ تحميل المؤسسات…', 'Loading blacklist…': 'جارٍ تحميل القائمة السوداء…', 'Loading draft routes…': 'جارٍ تحميل المسارات…', 'Loading tariff…': 'جارٍ تحميل التعرفة…', 'Loading pending accounts…': 'جارٍ تحميل الحسابات المعلّقة…', 'Loading employees…': 'جارٍ تحميل الموظفين…', 'Loading package versions…': 'جارٍ تحميل إصدارات الحزم…', 'Loading organisations…': 'جارٍ تحميل المؤسسات…', 'Loading captains awaiting review…': 'جارٍ تحميل الكباتن بانتظار المراجعة…', 'Loading documents…': 'جارٍ تحميل الوثائق…', 'Loading profile…': 'جارٍ تحميل الملف الشخصي…', 'Loading campaign history…': 'جارٍ تحميل سجل الحملات…', 'Loading organisations…': 'جارٍ تحميل المؤسسات…', 'Loading organisations…': 'جارٍ تحميل المؤسسات…', // Growth page 'Passengers in total': 'إجمالي الركاب', 'Captains in total': 'إجمالي الكباتن', 'Passengers joined · 30 days': 'ركاب انضموا · 30 يوماً', 'Captains joined · 30 days': 'كباتن انضموا · 30 يوماً', 'Signups per day': 'التسجيلات يومياً', 'last 30 days': 'آخر 30 يوماً', 'Passengers': 'ركاب', 'Captains': 'كباتن', 'Nobody signed up in the last 30 days.': 'لم يُسجَّل أي شخص خلال آخر 30 يوماً.', // Analytics page 'Revenue is counted from completed rides only.': 'يُحتسب الإ revenue فقط من الرحلات المكتملة.', 'Revenue': 'الإيرادات', 'Total collected': 'إجمالي المحصّل', 'Platform share': 'نصيب المنصة', 'Average fare': 'متوسط الأجرة', 'Completed rides': 'الرحلات المكتملة', 'Captain ranking': 'تصنيف الكباتن', 'No completed rides carried a fare in this period.': 'لم تحمل أي رحلة مكتملة أجرة في هذه الفترة.', // Transit page 'Search by name': 'بحث بالاسم', 'New organisation': 'مؤسسة جديدة', 'No organisations registered yet.': 'لا توجد مؤسسات مسجّلة بعد.', 'Organisations': 'المؤسسات', 'total': 'إجمالي', 'Type': 'النوع', 'City': 'المدينة', 'Contract': 'العقد', 'Captains': 'الكباتن', 'Vehicles': 'المركبات', 'Routes': 'المسارات', 'Enrolled': 'المسجّلون', 'Edit organisation': 'تعديل المؤسسة', 'New organisation': 'مؤسسة جديدة', 'Save changes': 'حفظ التغييرات', 'Create organisation': 'إنشاء مؤسسة', 'Organisation updated.': 'تم تحديث المؤسسة.', 'Organisation created.': 'تم إنشاء المؤسسة.', // Org details 'Routes': 'المسارات', 'Recent trips': 'الرحلات الأخيرة', 'Administrators': 'المديرون', 'No routes yet.': 'لا توجد مسارات بعد.', 'No administrators yet.': 'لا يوجد مديرون بعد.', 'Add': 'إضافة', 'Disable': 'تعطيل', 'Enable': 'تفعيل', 'Administrator added.': 'تمت إضافة المدير.', 'Administrator enabled.': 'تم تفعيل المدير.', 'Administrator disabled.': 'تم تعطيل المدير.', // Blacklist 'Removal is permanent.': 'الحذف دائم.', 'Remove & blacklist': 'حذف و加入 القائمة السوداء', 'Lift a block': 'رفع الحظر', 'Delete and blacklist': 'حذف و加入 القائمة السوداء', 'Currently blocked': 'المحظورون حالياً', 'Enter the phone number of the account to remove.': 'أدخل رقم هاتف الحساب المراد حذفه.', 'The retyped phone does not match': 'رقم الهاتف المُعاد إدخاله غير متطابق', 'Removed and blacklisted': 'تم الحذف والإضافة للقائمة السوداء', 'Block lifted for': 'تم رفع الحظر عن', 'Block lifted.': 'تم رفع الحظر.', 'Account type': 'نوع الحساب', 'Phone': 'الهاتف', 'Account ID': 'معرّف الحساب', 'Reason': 'السبب', 'Retype the phone to confirm': 'أعد إدخال الهاتف للتأكيد', 'Passenger': 'راكب', // Campaign 'Campaign parameters': 'معايير الحملة', 'Country': 'الدولة', 'Jordan': 'الأردن', 'Syria': 'سوريا', 'Egypt': 'مصر', 'Iraq': 'العراق', 'Region': 'المنطقة', 'Siro base price': 'السعر الأساسي لسير', 'Preview': 'معاينة', 'Launch campaign': 'إطلاق الحملة', 'Preview first to enable launching.': 'عاين أولاً لتفعيل الإطلاق.', 'No analysis run yet.': 'لم تُنفَّذ أي تحليل بعد.', 'Analysing…': 'جارٍ التحليل…', 'Launching…': 'جارٍ الإطلاق…', 'Running market analysis…': 'جارٍ تحليل السوق…', 'Preview': 'معاينة', 'Launch result': 'نتيجة الإطلاق', 'Campaign history': 'سجل الحملات', 'Previewed': 'تمت المعاينة', 'Campaign dispatched.': 'تم إرسال الحملة.', // App versions 'Update version': 'تحديث الإصدار', 'Version set to': 'تم تعيين الإصدار إلى', 'Version unchanged.': 'الإصدار لم يتغيّر.', 'No package rows configured.': 'لم تُعدَّ أي حزم.', // Driver docs 'Awaiting review': 'بانتظار المراجعة', 'No captains are awaiting document review.': 'لا يوجد كباتن بانتظار مراجعة الوثائق.', 'Review documents': 'مراجعة الوثائق', 'Approve & activate': 'اعتماد وتفعيل', 'Captain activated.': 'تم تفعيل الكابتن.', // Staff 'Add a staff account': 'إضافة حساب موظف', 'Create account': 'إنشاء حساب', 'Account activated.': 'تم تفعيل الحساب.', 'Pending activation': 'بانتظار التفعيل', 'Employees': 'الموظفون', 'Activate': 'تفعيل', 'Activate captain': 'تفعيل الكابتن', 'They will be able to accept rides immediately.': 'سيتمكّن من قبول الرحلات فوراً.', // Route approvals 'No routes are waiting for approval.': 'لا توجد مسارات بانتظار الاعتماد.', 'Direction': 'الاتجاه', 'Distance': 'المسافة', 'Duration': 'المدة', 'Stops': 'المحطات', 'Submitted': 'تاريخ التقديم', 'Reject': 'رفض', 'Approve': 'اعتماد', 'major': 'رئيسية', 'This route has no stops recorded.': 'هذا المسار ليس له محطات مسجّلة.', // Broadcast 'Compose': 'إنشاء الرسالة', 'Preview': 'معاينة', 'Audience': 'الجمهور', 'All captains': 'جميع الكباتن', 'All passengers': 'جميع الركاب', 'Notification title': 'عنوان الإشعار', 'Message text': 'نص الرسالة', 'Review & send': 'مراجعة وإرسال', 'Notification delivered to': 'تم تسليم الإشعار لـ', 'Send failed:': 'فشل الإرسال:', // Tariff editor 'Platform commission': 'عمولة المنصة', '% taken by Siro': '% تأخذها سير', 'Fuel price': 'سعر الوقود', 'Currency': 'العملة', 'Minimum fare — normal': 'الحد الأدنى — عادي', 'Minimum fare — peak': 'الحد الأدنى — ذروة', 'Minimum fare — late night': 'الحد الأدنى — متأخر', 'Fixed price': 'السعر الثابت', 'Speed': 'سبيد', 'Comfort': 'كومفورت', 'Lady': 'ليدي', 'Electric': 'كهرباء', 'Van': 'فان', 'Delivery': 'توصيل', 'Mishwar VIP': 'ميشوار VIP', 'Awfar': 'أوفار', 'Tariff updated and recorded in the audit log.': 'تم تحديث التعرفة وتسجيلها في سجل العمليات.', // Ride monitor 'Look up': 'بحث', 'Enter a value above to query this endpoint.': 'أدخل قيمة أعلاه للاستعلام عن هذا الـ API.', 'Querying…': 'جارٍ الاستعلام…', 'Active ride for a phone number': 'الرحلة النشطة لرقم هاتف', 'Gift payment eligibility': 'أهلية الدفع كهدية', 'Passenger or captain phone, e.g. 962798583052': 'هاتف الراكب أو الكابتن، مثال: 962798583052', 'Captain phone': 'هاتف الكابتن', // Ride details 'Trip #': 'رحلة #', 'Status': 'الحالة', 'Passenger': 'الراكب', 'Passenger phone': 'هاتف الراكب', 'Completed trips (passenger)': 'الرحلات المكتملة (الراكب)', 'Captain phone': 'هاتف الكابتن', 'Captain completed / cancelled': 'الكابتن مكتملة / ملغاة', 'Pickup': 'نقطة الت Pickup', 'Drop-off': 'نقطة النزول', 'Passenger fare': 'أجرة الراكب', 'Captain earning': 'أرباح الكابتن', 'Payment method': 'طريقة الدفع', 'Service': 'الخدمة', 'Requested': 'طُلبت', 'Started': 'بدأت', 'Finished': 'انتهت', 'Cancellation note': 'سبب الإلغاء', // Table messages 'Showing': 'عرض', 'of': 'من', 'trips': 'رحلات', 'trip': 'رحلة', 'page': 'صفحة', 'captains': 'كباتن', 'Search results for': 'نتائج البحث عن', 'No rides match this filter.': 'لا توجد رحلات تطابق هذا الفلتر.', 'No captains found.': 'لم يُعثر على كباتن.', 'No passengers found.': 'لم يُعثر على ركاب.', 'No pending requests.': 'لا توجد طلبات معلّقة.', 'No ride data': 'لا توجد بيانات رحلات', 'TOTAL RIDES': 'إجمالي الرحلات', // Session info 'Signed in as': 'تم الدخول كـ', 'Admin ID': 'معرّف المشرف', 'Role': 'الدور', 'Token expires': 'انتهاء الصلاحية', 'Device fingerprint': 'بصمة الجهاز', 'API endpoint': 'عنوان الـ API', 'Console build': 'إصدار المنصة', // Common UI 'super admin only': 'للمشرف العام فقط', 'Waiting for a captain': 'بانتظار كابتن', 'All statuses': 'جميع الحالات', 'Trips, assigned captains, fares and live status': 'الرحلات والكباتن المعيّنون والأجور والحالة الحيّة', 'Fleet roster, ratings and trip volume': 'قائمة الأسطول والتقييمات وعدد الرحلات', 'Most active customer accounts by trip count': 'أكثر حسابات العملاء نشاطاً حسب عدد الرحلات', 'Earnings, fares and distance aggregates across all recorded trips': 'الأرباح والأجور والمسافات لكل الرحلات المسجّلة', 'Admin and service accounts awaiting activation': 'حسابات المشرفين والخدمة بانتظار التفعيل', 'Volume of customer complaints filed': 'عدد الشكاوى المقدَّمة', 'Details of the credential currently authorising this console': 'تفاصيل الاعتماد الذي يخوّل هذه المنصة حالياً', // WhatsApp & encryption 'Send message': 'إرسال الرسالة', 'Decrypted value:': 'القيمة المفكّكة:', 'Encrypted value:': 'القيمة المشفرة:', 'Result appears here.': 'النتيجة تظهر هنا.', // Ride statuses already exist, just make sure these are covered: 'No rides found for': 'لم تُعثر على رحلات لـ', 'No captain matches': 'لا يوجد كابتن يطابق', 'No passenger matches': 'لا يوجد راكب يطابق', 'No profile returned for this record.': 'لم تُرجَع ملفات لهذا السجل.', 'This browser is registered as a trusted device:': 'هذا المتصفح مسجّل كجهاز موثوق:', 'The endpoint responded successfully but returned no data yet.': 'استجاب الـ API بنجاح لكنه لم يُرجِع بياناتاً بعد.', 'No records recorded for this yet — the table is empty in the database.': 'لا توجد سجلات بعد — الجدول فارغ في قاعدة البيانات.', 'Yes': 'نعم', 'No': 'لا', 'Click any row to open the full profile': 'اضغط على أي سطر لفتح الملف الشخصي الكامل', 'Search rides by phone number': 'بحث في الرحلات برقم الهاتف', 'Search by captain ID, phone or email': 'بحث بمعرّف الكابتن أو البريد الإلكتروني أو الهاتف', 'Search by passenger ID, phone or email': 'بحث بمعرّف الراكب أو البريد الإلكتروني أو الهاتف', }; const t = (key) => (lang === 'ar' ? (AR[key] || key) : key); function applyLanguage() { const rtl = lang === 'ar'; document.documentElement.lang = rtl ? 'ar' : 'en'; document.documentElement.dir = rtl ? 'rtl' : 'ltr'; document.querySelectorAll('[data-i18n]').forEach((node) => { node.textContent = t(node.dataset.i18n); }); document.querySelectorAll('[data-i18n-ph]').forEach((node) => { node.placeholder = t(node.dataset.i18nPh); }); document.querySelectorAll('[data-i18n-title]').forEach((node) => { node.title = t(node.dataset.i18nTitle); }); const toggle = $('langToggle'); if (toggle) toggle.textContent = rtl ? 'EN' : 'ع'; } function rebuildModuleLabels() { document.querySelectorAll('.sidebar-menu .menu-label').forEach((node) => { const key = Object.keys(AR).find((k) => AR[k] === node.textContent) || node.textContent; node.textContent = t(key); }); document.querySelectorAll('.nav-item span').forEach((span) => { const key = Object.keys(AR).find((k) => AR[k] === span.textContent) || span.textContent; span.textContent = t(key); }); document.querySelectorAll('.page-view .page-title h1').forEach((h) => { const key = Object.keys(AR).find((k) => AR[k] === h.textContent) || h.textContent; h.textContent = t(key); }); document.querySelectorAll('.page-view .page-title p').forEach((p) => { const key = Object.keys(AR).find((k) => AR[k] === p.textContent) || p.textContent; p.textContent = t(key); }); document.querySelectorAll('[data-reload]').forEach((btn) => { const span = btn.querySelector('span'); if (span) span.textContent = t('Reload'); else { const i = btn.querySelector('i'); if (i && !span) btn.innerHTML = ` ${t('Reload')}`; } }); } function setLanguage(next) { lang = next; localStorage.setItem(LANG_KEY, next); applyLanguage(); rebuildModuleLabels(); const active = document.querySelector('.nav-item.active'); if (active && session) refreshView(active.dataset.view, active.dataset.module); } const SESSION_KEY = 'siro_admin_user'; const FP_KEY = 'siro_web_fp'; const API_BASE_KEY = 'siro_api_base'; // The Flutter admin app talks to a per-country host (see // siro_admin/lib/constant/links.dart). The web console defaults to the // backend deployed next to it, but the operator can repoint it. const API_CANDIDATES = [ { label: 'Same origin (/backend)', value: '/backend' }, { label: 'Jordan — jordan-siro.intaleqapp.com', value: 'https://jordan-siro.intaleqapp.com/backend' }, { label: 'Default — api.siromove.com', value: 'https://api.siromove.com/siro_v3' }, { label: 'Syria — api-syria.siromove.com', value: 'https://api-syria.siromove.com/siro_v3' }, { label: 'Egypt — api-egypt.siromove.com', value: 'https://api-egypt.siromove.com/siro_v3' }, ]; let API_BASE = localStorage.getItem(API_BASE_KEY) || '/backend'; // ── Session state ──────────────────────────────────────────────────────── let session = null; // { id, name, role, jwt, issuedAt, expiresIn } let deviceFingerprint = ''; let stats = null; // latest row from dashbord.php let driversPage = 1; let driversPages = 1; let allRides = []; let ridesShown = 0; const RIDES_PAGE_SIZE = 25; const $ = (id) => document.getElementById(id); const el = {}; document.addEventListener('DOMContentLoaded', init); async function init() { cacheElements(); applyLanguage(); deviceFingerprint = await resolveFingerprint(); if (el.fpPreview) el.fpPreview.textContent = deviceFingerprint.slice(0, 12) + '…'; const stamp = $('buildStamp'); if (stamp) stamp.textContent = `build ${BUILD}`; buildModules(); setupNavigation(); setupAuthEvents(); setupDataEvents(); setupDiagnostics(); session = readSession(); if (session?.jwt) { showConsole(); loadEverything(); } else { showLogin(); } } function cacheElements() { [ 'authWrapper', 'loginForm', 'loginEmail', 'loginPass', 'loginSubmitBtn', 'fpPreview', 'loginError', 'otpModal', 'otpInput', 'otpPhoneText', 'submitOtpBtn', 'sidebar', 'toggleSidebar', 'logoutBtn', 'refreshBtn', 'globalSearch', 'connectionPill', 'connectionText', 'lastUpdated', 'userName', 'userRole', 'userAvatar', 'approvalsCount', 'ridesTableBody', 'ridesMeta', 'rideStatusFilter', 'driversTableBody', 'driversMeta', 'driversPrev', 'driversNext', 'passengersTableBody', 'approvalsTableBody', 'ridesSearch', 'ridesSearchBtn', 'ridesMore', 'driversSearch', 'driversSearchBtn', 'passengersSearch', 'passengersSearchBtn', 'decryptInput', 'decryptBtn', 'decryptOutput', 'statusLegend', 'serviceMix', 'tripPerformance', 'sessionInfo', 'apiBaseSelect', 'apiBaseCustom', 'saveApiBaseBtn', 'runDiagnosticsBtn', 'copyDiagnosticsBtn', 'diagnosticsOutput', ].forEach((id) => { el[id] = $(id); }); } // ── Device fingerprint ─────────────────────────────────────────────────── // The backend binds every access token to this value (JwtService compares // sha256(X-Device-FP + pepper) against the claim inside the JWT), so it must // stay identical for the whole life of the browser profile. async function resolveFingerprint() { const cached = localStorage.getItem(FP_KEY); if (cached) return cached; const traits = [ navigator.userAgent, navigator.platform || '', (navigator.languages || [navigator.language]).join(','), Intl.DateTimeFormat().resolvedOptions().timeZone || '', `${screen.width}x${screen.height}x${screen.colorDepth}`, String(navigator.hardwareConcurrency || 0), String(navigator.maxTouchPoints || 0), canvasSignature(), ].join('|'); let fp; try { const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(traits)); fp = 'web_' + [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join(''); } catch { // crypto.subtle is unavailable over plain HTTP — fall back to a random, // still-persistent identifier. fp = 'web_' + Math.random().toString(36).slice(2) + Date.now().toString(36); } localStorage.setItem(FP_KEY, fp); return fp; } function canvasSignature() { try { const c = document.createElement('canvas'); c.width = 200; c.height = 40; const ctx = c.getContext('2d'); ctx.textBaseline = 'top'; ctx.font = '14px Arial'; ctx.fillStyle = '#f60'; ctx.fillRect(0, 0, 100, 20); ctx.fillStyle = '#069'; ctx.fillText('siro-admin', 2, 4); return c.toDataURL().slice(-64); } catch { return 'no-canvas'; } } // ── API layer ──────────────────────────────────────────────────────────── // Every protected endpoint goes through connect.php → JwtService::authenticate, // which requires BOTH the bearer token and the X-Device-FP header. // NOTE: the backend's filterRequest() reads POST and JSON bodies only — it // never looks at $_GET. Any parameter sent as a query string is silently // dropped and the endpoint falls back to its default (which is why the ride // status filter always returned "Begin" and captain paging never advanced). // Every parameter therefore goes in a POST body. async function api(path, { method = null, body = null, params = null, auth = true } = {}) { const headers = { 'X-Device-FP': deviceFingerprint }; if (auth && session?.jwt) headers.Authorization = `Bearer ${session.jwt}`; if (params && !body) { body = new FormData(); Object.entries(params).forEach(([k, v]) => { if (v !== null && v !== undefined && v !== '') body.append(k, v); }); } const res = await fetch(API_BASE + path, { method: method || (body ? 'POST' : 'GET'), headers, body, }); const text = await res.text(); let json; try { json = JSON.parse(text); } catch { const preview = text.trim().slice(0, 200); const hint = preview.includes(' { if (e.key === 'Enter') onVerifyOtp(); }); el.logoutBtn?.addEventListener('click', () => signOut('You have been signed out.')); window.closeOtpModal = () => el.otpModal?.classList.remove('active'); } function showLoginError(message, detail) { if (!el.loginError) return; el.loginError.hidden = false; el.loginError.innerHTML = `
${esc(message)} ${detail ? `${esc(detail)}` : ''}
`; } function clearLoginError() { if (el.loginError) el.loginError.hidden = true; } async function onLogin(e) { e.preventDefault(); const phone = el.loginEmail.value.trim(); const password = el.loginPass.value; if (!phone || !password) return; clearLoginError(); busy(el.loginSubmitBtn, true, t('Verifying…')); try { const form = new FormData(); form.append('phone', phone); form.append('password', password); form.append('fingerprint', deviceFingerprint); form.append('aud', 'admin'); const payload = await api('/Admin/auth/login.php', { method: 'POST', body: form, auth: false }); if (payload?.status === 'otp_required') { pendingPhone = phone; el.otpPhoneText.textContent = `Enter the 3-digit code sent to ${payload.phone || 'your WhatsApp'}.`; el.otpModal.classList.add('active'); el.otpInput.value = ''; el.otpInput.focus(); toast(payload.message || 'Verification code sent.', 'info'); } else if (payload?.jwt) { // Trusted-device renewal path — no OTP required. establishSession(payload); } else { showLoginError( t('The server accepted the request but issued no code and no token.'), JSON.stringify(payload).slice(0, 300) ); } } catch (err) { showLoginError(err.message, err.status ? `HTTP ${err.status}` : null); toast(err.message, 'danger'); } finally { busy(el.loginSubmitBtn, false, t('Sign In')); } } async function onVerifyOtp() { const otp = el.otpInput.value.trim(); if (otp.length < 3) { toast('Enter the 3-digit code.', 'warning'); return; } busy(el.submitOtpBtn, true, 'Verifying…'); try { const form = new FormData(); form.append('otp', otp); form.append('fingerprint', deviceFingerprint); form.append('aud', 'admin'); const payload = await api('/Admin/auth/verify_login.php', { method: 'POST', body: form, auth: false }); if (!payload?.jwt) throw new ApiError('No token issued.', 0); el.otpModal.classList.remove('active'); establishSession(payload); } catch (err) { showLoginError(err.message, err.status ? `HTTP ${err.status}` : null); toast(err.message, 'danger'); } finally { busy(el.submitOtpBtn, false, t('Verify & Sign In')); } } function establishSession(payload) { const admin = payload.admin || {}; session = { id: admin.id ?? null, name: admin.name || 'Admin', email: admin.email || pendingPhone, role: admin.role || 'admin', jwt: payload.jwt, issuedAt: Date.now(), expiresIn: Number(payload.expires_in) || 3600, }; localStorage.setItem(SESSION_KEY, JSON.stringify(session)); showConsole(); toast(`Welcome back, ${session.name}.`, 'success'); loadEverything(); } function readSession() { try { const raw = JSON.parse(localStorage.getItem(SESSION_KEY) || 'null'); if (!raw?.jwt) return null; // Access tokens live one hour; drop anything already expired locally so // we show the login form rather than a wall of failed requests. const age = (Date.now() - (raw.issuedAt || 0)) / 1000; if (age >= (raw.expiresIn || 3600)) return null; return raw; } catch { return null; } } function signOut(message) { localStorage.removeItem(SESSION_KEY); session = null; stats = null; loadedModules.clear(); showLogin(); if (message) toast(message, 'info'); } function showLogin() { el.authWrapper.classList.remove('hidden'); el.loginPass.value = ''; } function showConsole() { el.authWrapper.classList.add('hidden'); el.userName.textContent = session.name; el.userRole.textContent = formatRole(session.role); el.userAvatar.textContent = initials(session.name); applyRoleVisibility(); } // ── Data loading ───────────────────────────────────────────────────────── function loadEverything() { setConnection('loading', t('Loading live data…')); Promise.allSettled([ loadStats(), loadRides(), loadDrivers(), loadPassengers(), loadApprovals(), loadRidesTrend(), ]).then((results) => { renderSessionInfo(); const ok = results.some((r) => r.status === 'fulfilled'); if (ok) { setConnection('live', t('Live database')); el.lastUpdated.textContent = t('Updated') + ' ' + new Date().toLocaleTimeString(); } else if (session) { setConnection('error', t('No data from API')); el.lastUpdated.textContent = 'No data — check diagnostics'; toast('Every endpoint failed. Open “Session & Security” → Run diagnostics.', 'danger'); } }); } async function loadStats() { try { const payload = await api('/Admin/dashbord.php'); stats = Array.isArray(payload) ? payload[0] : payload; renderStats(); } catch (err) { handleApiError(err, 'stats'); markKpisUnavailable(); throw err; } } async function loadRides() { const status = el.rideStatusFilter?.value || 'All'; tableMessage(el.ridesTableBody, 8, t('Loading…')); if (el.ridesMore) el.ridesMore.hidden = true; try { const rides = await api('/Admin/rides/get_rides_by_status.php', { params: { status } }); allRides = Array.isArray(rides) ? rides : []; ridesShown = RIDES_PAGE_SIZE; renderRides(); } catch (err) { if (!handleApiError(err, 'rides')) tableMessage(el.ridesTableBody, 8, err.message, true); el.ridesMeta.textContent = '—'; allRides = []; if (el.ridesMore) el.ridesMore.hidden = true; throw err; } } async function loadDrivers() { tableMessage(el.driversTableBody, 8, t('Loading…')); try { const payload = await api('/Admin/AdminCaptain/get.php', { params: { page: driversPage } }); driversPages = payload.pages || 1; renderDrivers(payload.data || [], payload.total || 0); } catch (err) { if (!handleApiError(err, 'drivers')) tableMessage(el.driversTableBody, 8, err.message, true); throw err; } } async function loadPassengers() { tableMessage(el.passengersTableBody, 8, t('Loading…')); try { const rows = await api('/Admin/getPassengerDetails.php'); renderPassengers(Array.isArray(rows) ? rows : []); } catch (err) { if (!handleApiError(err, 'passengers')) tableMessage(el.passengersTableBody, 8, err.message, true); throw err; } } async function loadApprovals() { tableMessage(el.approvalsTableBody, 7, t('Loading…')); try { const payload = await api('/Admin/Staff/pending.php'); renderApprovals(payload?.data || []); } catch (err) { if (!handleApiError(err, 'approvals')) tableMessage(el.approvalsTableBody, 7, err.message, true); throw err; } } async function loadRidesTrend() { try { const rows = await api('/Admin/AdminRide/getRidesPerMonth.php'); const series = (Array.isArray(rows) ? rows : []).slice(-21).map((r) => ({ label: `${String(r.day).padStart(2, '0')}/${String(r.month).padStart(2, '0')}`, value: Number(r.rides_count) || 0, })); drawLineChart('ridesTrendChart', series); } catch (err) { handleApiError(err, 'rides-trend'); throw err; } } // ── Renderers ──────────────────────────────────────────────────────────── function renderStats() { if (!stats) return; const total = num(stats.countRide); const completed = num(stats.completed_rides); setKpi('rides', fmtInt(total)); setKpi('ridesMonth', fmtInt(stats.countRideThisMonth)); setKpi('completed', fmtInt(completed)); setKpi('completionRate', total ? `${((completed / total) * 100).toFixed(1)}%` : '—'); setKpi('drivers', fmtInt(stats.countDriver)); setKpi('driversMonth', fmtInt(stats.countDriverThisMonth)); setKpi('passengers', fmtInt(stats.countPassengers)); setKpi('passengersMonth', fmtInt(stats.countPassengersThisMonth)); // A null aggregate means "nothing recorded yet" — showing 0.00 would read // as a measured zero. setKpi('driverEarnings', stats.total_driver_earnings == null ? '—' : fmtMoney(stats.total_driver_earnings)); setKpi('avgFare', stats.avg_passenger_price == null ? '—' : fmtMoney(stats.avg_passenger_price)); setKpi('totalDistance', `${fmtInt(stats.total_distance)} km`); setKpi('complaintsToday', fmtInt(stats.countComplaintToday)); setKpi('complaintsWeek', fmtInt(stats.countComplaintThisWeek)); setKpi('complaintsMonth', fmtInt(stats.countComplaintThisMonth)); drawDonut('statusDonut', [ { label: t('Completed'), value: num(stats.completed_rides), color: '#10b981' }, { label: t('Waiting'), value: num(stats.ongoing_rides), color: '#6366f1' }, { label: t('Cancelled'), value: num(stats.cancelled_rides), color: '#f43f5e' }, ], el.statusLegend); drawBarChart('timeOfDayChart', [ { label: t('Morning 6–11'), value: num(stats.morning_ride_count) }, { label: t('Afternoon 12–17'), value: num(stats.evening_ride_count) }, { label: t('Night 18–5'), value: num(stats.night_ride_count) }, ]); renderServiceMix(); renderTripPerformance(); } function renderServiceMix() { const types = [ { label: t('Comfort'), value: num(stats.comfort), color: '#6366f1' }, { label: t('Speed'), value: num(stats.speed), color: '#06b6d4' }, { label: t('Lady'), value: num(stats.lady), color: '#8b5cf6' }, ]; const total = types.reduce((s, t) => s + t.value, 0) || 1; el.serviceMix.innerHTML = types.map((t) => `
${t.label} ${fmtInt(t.value)} ${((t.value / total) * 100).toFixed(1)}%
`).join(''); } function renderTripPerformance() { const rows = [ [t('Average trip duration'), stats.driver_avg_duration || '—'], [t('Longest trip duration'), stats.longest_duration || '—'], [t('Average distance'), `${fmtNum(stats.average_distance)} km`], [t('Longest distance'), `${fmtNum(stats.longest_distance)} km`], [t('Cancelled rides'), fmtInt(stats.cancelled_rides)], [t('Captains with completed trips'), fmtInt(stats.num_Driver)], ]; el.tripPerformance.innerHTML = rows.map(([k, v]) => `
${k}${v}
`).join(''); } function renderRides() { const rides = allRides.slice(0, ridesShown); el.ridesMeta.textContent = allRides.length ? `${t('Showing')} ${rides.length} ${t('of')} ${allRides.length} ${allRides.length === 1 ? (lang === 'ar' ? 'رحلة' : 'trip') : t('trips')}` : '—'; el.ridesMore.hidden = ridesShown >= allRides.length; if (!rides.length) { tableMessage(el.ridesTableBody, 8, t('No rides match this filter.')); return; } el.ridesTableBody.innerHTML = rides.map((r) => ` #${esc(r.id)} ${esc(r.passenger_full_name || 'Unknown')} ${esc(r.driver_full_name || 'Unassigned')} ${routeCell(r)} ${fmtMoney(rideFare(r))} ${esc(labelStatus(r.status))} ${esc(rideTimestamp(r))} `).join(''); el.ridesTableBody.querySelectorAll('[data-ride]').forEach((btn) => { btn.addEventListener('click', () => { const ride = allRides.find((r) => String(r.id) === btn.dataset.ride); if (ride) showRideDetails(ride); }); }); } // The ride table carries two generations of columns. Older rows expose // address_start/address_end, price_for_passenger and created_at; rows written // by the current ride pipeline expose start_location/end_location as // "lat,lng" pairs, a plain `price`, and separate date + time columns. function rideFare(r) { return r.price_for_passenger ?? r.price ?? 0; } function rideTimestamp(r) { if (r.created_at) return fmtDate(r.created_at); if (r.date) return fmtDate(`${r.date} ${r.time && r.time !== '00:00:00' ? r.time : ''}`.trim()); return '—'; } function routeCell(r) { if (r.address_start || r.address_end) { return ` ${esc(shorten(r.address_start))} ${esc(shorten(r.address_end))}`; } if (r.start_location) { const link = mapLink(r.start_location); return ` ${esc(shortCoord(r.start_location))} ${esc(shortCoord(r.end_location))} `; } return '—'; } function shortCoord(value) { if (!value) return '—'; return String(value).split(',').map((n) => Number(n).toFixed(4)).join(', '); } function mapLink(coords) { return `https://www.openstreetmap.org/?mlat=${encodeURIComponent(String(coords).split(',')[0])}&mlon=${encodeURIComponent(String(coords).split(',')[1] || '')}#map=15/`; } // ── Role model ─────────────────────────────────────────────────────────── // Mirrors the Flutter admin app: a plain `admin` observes, a `super_admin` // edits, approves and sees unmasked contact details. const isSuperAdmin = () => session?.role === 'super_admin'; function maskPhone(phone) { if (!phone || phone === '—') return '—'; if (isSuperAdmin()) return String(phone); const s = String(phone); return s.length > 6 ? `${s.slice(0, 4)}****${s.slice(-2)}` : '****'; } function applyRoleVisibility() { document.querySelectorAll('[data-requires-super]').forEach((node) => { node.hidden = !isSuperAdmin(); }); } // ── Lookup by phone / id ───────────────────────────────────────────────── async function lookupRidesByPhone(phone) { tableMessage(el.ridesTableBody, 8, `${lang === 'ar' ? 'جارٍ البحث عن رحلات لـ' : 'Searching rides for'} ${phone}…`); try { const payload = await api('/Admin/rides/admin_get_rides_by_phone.php', { params: { phone } }); const rows = Array.isArray(payload) ? payload : (payload?.rides || payload?.data || []); allRides = rows; ridesShown = RIDES_PAGE_SIZE; renderRides(); if (!rows.length) tableMessage(el.ridesTableBody, 8, `No rides found for ${phone}.`); } catch (err) { if (!handleApiError(err, 'ride-lookup')) tableMessage(el.ridesTableBody, 8, err.message, true); } } async function lookupCaptain(term) { tableMessage(el.driversTableBody, 8, `${lang === 'ar' ? 'جارٍ البحث عن كباتن لـ' : 'Searching captains for'} ${term}…`); const params = /^\d+$/.test(term) && term.length < 8 ? { driver_id: term } : (term.includes('@') ? { driverEmail: term } : { driverPhone: term }); try { const payload = await api('/Admin/AdminCaptain/getCaptainDetailsByEmailOrIDOrPhone.php', { params }); const rows = normaliseRows(payload); renderDrivers(rows, rows.length); el.driversMeta.textContent = `Search results for “${term}”`; if (!rows.length) tableMessage(el.driversTableBody, 8, `No captain matches “${term}”.`); } catch (err) { if (!handleApiError(err, 'captain-lookup')) tableMessage(el.driversTableBody, 8, err.message, true); } } async function lookupPassenger(term) { tableMessage(el.passengersTableBody, 8, `${lang === 'ar' ? 'جارٍ البحث عن ركاب لـ' : 'Searching passengers for'} ${term}…`); const params = /^\d+$/.test(term) && term.length < 8 ? { passengerId: term } : (term.includes('@') ? { passengerEmail: term } : { passengerphone: term }); try { const payload = await api('/Admin/getPassengerbyEmail.php', { params }); const rows = normaliseRows(payload); renderPassengers(rows); if (!rows.length) tableMessage(el.passengersTableBody, 8, `No passenger matches “${term}”.`); } catch (err) { if (!handleApiError(err, 'passenger-lookup')) tableMessage(el.passengersTableBody, 8, err.message, true); } } function normaliseRows(payload) { if (Array.isArray(payload)) return payload; if (payload && typeof payload === 'object') { if (Array.isArray(payload.data)) return payload.data; return [payload]; } return []; } // ── Profile drawers ────────────────────────────────────────────────────── async function openCaptainProfile(id) { openProfile(`Captain #${id}`, () => api('/Admin/AdminCaptain/getCaptainDetailsById.php', { params: { driver_id: id } })); } async function openPassengerProfile(id) { openProfile(`Passenger #${id}`, () => api('/Admin/getPassengerDetailsByPassengerID.php', { params: { passengerID: id } })); } async function openProfile(title, fetcher) { const body = $('modalBodyContent'); body.innerHTML = `
Loading profile…
`; $('detailsModal').classList.add('active'); try { const payload = await fetcher(); const record = normaliseRows(payload)[0]; if (!record) throw new ApiError('No profile returned for this record.', 0); const entries = Object.entries(record) .filter(([k]) => !/token|password|fingerprint/i.test(k)) .map(([k, v]) => [humanize(k), /phone/i.test(k) ? maskPhone(v) : formatValue(v, k)]); body.innerHTML = `
${entries.map(([k, v]) => `
${esc(k)}${esc(String(v))}
`).join('')}
`; } catch (err) { if (handleApiError(err, 'profile')) return; body.innerHTML = `
${esc(err.message)}
`; } } function renderDrivers(drivers, total) { el.driversMeta.textContent = `${t('page')} ${driversPage} ${t('of')} ${driversPages} · ${fmtInt(total)} ${t('captains')}`; if (!drivers.length) { tableMessage(el.driversTableBody, 8, t('No captains found.')); return; } el.driversTableBody.innerHTML = drivers.map((d) => ` #${esc(d.id)} ${esc(`${d.first_name || ''} ${d.last_name || ''}`.trim() || 'Unnamed')} ${esc(maskPhone(d.phone))} ${esc(d.email || '—')} ${rating(d.passengerAverageRating)} ${fmtInt(d.countPassengerRide)} ${fmtInt(d.countPassengerCancel)} ${esc(d.status || 'unknown')} `).join(''); el.driversTableBody.querySelectorAll('[data-captain]').forEach((row) => row.addEventListener('click', () => openCaptainProfile(row.dataset.captain))); } function renderPassengers(rows) { if (!rows.length) { tableMessage(el.passengersTableBody, 8, t('No passengers found.')); return; } el.passengersTableBody.innerHTML = rows.map((p) => ` #${esc(p.id)} ${esc(`${p.first_name || ''} ${p.last_name || ''}`.trim() || 'Unnamed')} ${esc(p.email || maskPhone(p.phone))} ${fmtInt(p.countPassengerRide)} ${rating(p.passengerAverageRating)} ${fmtInt(p.countPassengerCancel)} ${esc(fmtDate(p.created_at, true))} ${esc(p.status || 'unknown')} `).join(''); el.passengersTableBody.querySelectorAll('[data-passenger]').forEach((row) => row.addEventListener('click', () => openPassengerProfile(row.dataset.passenger))); } function renderApprovals(pending) { el.approvalsCount.hidden = pending.length === 0; el.approvalsCount.textContent = pending.length; if (!pending.length) { tableMessage(el.approvalsTableBody, 7, t('No pending requests.')); return; } const isSuper = session?.role === 'super_admin'; el.approvalsTableBody.innerHTML = pending.map((p) => ` #${esc(p.id)} ${esc(p.name || '—')} ${esc(p.phone || '—')} ${esc(p.type)} ${esc(p.role || '—')} ${esc(fmtDate(p.created_at, true))} ${(isSuper && p.type === 'admin') ? ` ` : `${t('super admin only')}`} `).join(''); el.approvalsTableBody.querySelectorAll('[data-approve]').forEach((b) => b.addEventListener('click', () => decideApproval(b.dataset.approve, 'approved'))); el.approvalsTableBody.querySelectorAll('[data-reject]').forEach((b) => b.addEventListener('click', () => decideApproval(b.dataset.reject, 'rejected'))); } async function decideApproval(adminId, action) { if (!confirm(`Set admin #${adminId} to "${action}"?`)) return; try { const form = new FormData(); form.append('admin_id', adminId); form.append('action', action); await api('/Admin/auth/approve_admin.php', { method: 'POST', body: form }); toast(`Admin #${adminId} ${action}.`, 'success'); loadApprovals(); } catch (err) { if (!handleApiError(err, 'approve')) toast(err.message, 'danger'); } } function renderSessionInfo() { if (!session) return; const expiresAt = new Date(session.issuedAt + session.expiresIn * 1000); const rows = [ [t('Signed in as'), session.name], [t('Admin ID'), session.id ?? '—'], [t('Role'), formatRole(session.role)], [t('Token expires'), expiresAt.toLocaleString()], [t('Device fingerprint'), deviceFingerprint.slice(0, 24) + '…'], [t('API endpoint'), location.origin + API_BASE], [t('Console build'), BUILD], ]; el.sessionInfo.innerHTML = rows.map(([k, v]) => `
${k}${esc(String(v))}
`).join(''); } // ── Extended modules (parity with the Flutter admin app) ──────────────── // Each entry becomes a sidebar item plus a lazily-loaded page. Panels are // rendered by shape, not by hand-written field lists, so an endpoint that // grows a column shows it without a code change here. const MODULES = [ { id: 'liveOps', group: 'Realtime & Analytics', icon: 'ph-broadcast', title: 'Live Operations', subtitle: 'Realtime fleet counters and the alerts that need attention now', panels: [ { title: 'Realtime counters', path: '/Admin/v2/realtime_dashboard.php' }, { title: 'Smart alerts', path: '/Admin/v2/smart_alerts.php' }, ], }, { id: 'growth', group: 'Realtime & Analytics', icon: 'ph-trend-up', title: 'Growth', subtitle: 'How many passengers and captains joined each day over the last 30 days', custom: renderGrowth, }, { id: 'analyticsV2', group: 'Realtime & Analytics', icon: 'ph-chart-line', title: 'Advanced Analytics', subtitle: 'Revenue, ranking and dashboard aggregates from the v2 engine', custom: renderAnalytics, }, { id: 'financeV2', group: 'Finance', icon: 'ph-bank', title: 'Financial V2', subtitle: 'Settlement runs and financial aggregates', panels: [ { title: 'Financial stats', path: '/Admin/v2/financial/stats.php' }, { title: 'Settlements', path: '/Admin/v2/financial/settlements.php' }, ], }, { id: 'marketing', group: 'Growth & Pricing', icon: 'ph-megaphone', title: 'Marketing Intelligence', subtitle: 'Market share, competitor price gaps, anomalies and campaign history', panels: [ { title: 'Market share', path: '/Admin/marketing/get_market_share_analytics.php', params: { country_code: 'JO' } }, { title: 'Price comparison', path: '/Admin/marketing/get_price_comparison.php', params: { country_code: 'JO' } }, { title: 'Market anomalies', path: '/Admin/marketing/get_market_anomalies.php', params: { country_code: 'JO' } }, { title: 'Surge opportunity index', path: '/Admin/marketing/surge_opportunity_index.php', params: { country_code: 'JO' } }, { title: 'Win-back hotspots', path: '/Admin/marketing/winback_hotspot_targets.php', params: { country_code: 'JO' } }, { title: 'Campaign log', path: '/Admin/marketing/get_campaigns_log.php', params: { country_code: 'JO' } }, ], }, { id: 'pricing', group: 'Growth & Pricing', icon: 'ph-sliders', title: 'Pricing Engine', subtitle: 'Stability log, AI predictions and the live price-gap heatmap', panels: [ { title: 'Pricing stability log', path: '/Admin/marketing/get_pricing_stability_log.php', params: { country_code: 'JO' } }, { title: 'AI price prediction', path: '/Admin/marketing/ai_price_prediction.php', params: { country_code: 'JO' } }, { title: 'Price gap heatmap', path: '/Admin/marketing/get_price_gap_heatmap.php', params: { country_code: 'JO' } }, { title: 'Telemetry', path: '/Admin/marketing/get_telemetry.php', params: { country_code: 'JO' } }, ], }, { id: 'tariff', group: 'Growth & Pricing', icon: 'ph-currency-circle-dollar', title: 'Tariff Editor', subtitle: 'The live Kazan tariff — every change here alters what passengers pay', custom: renderTariffEditor, }, { id: 'promos', group: 'Growth & Pricing', icon: 'ph-ticket', title: 'Promo Codes', subtitle: 'Active discount codes', panels: [{ title: 'Promo codes', path: '/ride/promo/get.php' }], }, { id: 'geofence', group: 'Growth & Pricing', icon: 'ph-map-trifold', title: 'Demand Heatmap', subtitle: 'Geofenced demand density', panels: [{ title: 'Heatmap', path: '/Admin/geofence/get_heatmap.php', params: { country_code: 'JO' } }], }, { id: 'fleet', group: 'Quality', icon: 'ph-steering-wheel', title: 'Fleet Performance', subtitle: 'Top captains, gift eligibility payouts and per-captain card charges', panels: [ { title: 'Best captains', path: '/Admin/driver/getBestDriver.php' }, { title: 'Card charges per captain', path: '/Admin/getVisaForEachDriver.php' }, ], }, { id: 'invoices', group: 'Finance', icon: 'ph-receipt', title: 'Invoices', subtitle: 'Invoice totals recorded against admin accounts', panels: [{ title: 'Invoice totals', path: '/Admin/adminUser/invoice_total.php' }], }, { id: 'quality', superOnly: true, group: 'Quality', icon: 'ph-prohibit', title: 'Blacklist & Removal', subtitle: 'Blocked accounts, permanent removals and lifting a block', custom: renderBlacklist, }, { id: 'scorecard', group: 'Quality', icon: 'ph-medal', title: 'Driver Scorecard', subtitle: 'Behaviour and reliability scoring per captain', panels: [{ title: 'Scorecard', path: '/Admin/v2/quality/driver_scorecard.php' }], }, { id: 'monitor', group: 'Realtime & Analytics', icon: 'ph-crosshair', title: 'Ride Monitor', subtitle: 'Look up the active ride and gift eligibility for a specific phone number', custom: (host) => renderLookupTools(host, [ { title: 'Active ride for a phone number', path: '/Admin/rides/monitorRide.php', field: 'phone', placeholder: 'Passenger or captain phone, e.g. 962798583052', }, { title: 'Gift payment eligibility', path: '/Admin/driver/getDriverGiftPayment.php', field: 'phone', placeholder: 'Captain phone', }, ]), }, { id: 'transit', group: 'Transit', icon: 'ph-bus', title: 'Mawasalati Organisations', subtitle: 'Universities, schools and companies running their own transport — open one to manage it', custom: renderTransitOrgs, }, { id: 'routes', group: 'Transit', icon: 'ph-path', title: 'Route Approvals', subtitle: 'Draft routes submitted by organisations, awaiting a decision', custom: renderRouteApprovals, }, { id: 'campaigns', superOnly: true, group: 'Growth & Pricing', icon: 'ph-rocket-launch', title: 'Campaign Launcher', subtitle: 'Generate an AI pricing campaign, preview it, then dispatch', custom: renderCampaigns, }, { id: 'appVersion', superOnly: true, group: 'Administration', icon: 'ph-device-mobile', title: 'App Versions', subtitle: 'The version each Siro app checks itself against', custom: renderAppVersions, }, { id: 'broadcast', superOnly: true, group: 'Administration', icon: 'ph-megaphone-simple', title: 'Broadcast Notification', subtitle: 'Push a notification to every captain or every passenger', custom: renderBroadcast, }, { id: 'driverDocs', group: 'Quality', icon: 'ph-identification-card', title: 'Driver Documents', subtitle: 'Captains awaiting document review and activation', custom: renderDriverDocs, }, { id: 'staff', superOnly: true, group: 'Administration', icon: 'ph-identification-badge', title: 'Staff & Employees', subtitle: 'Internal staff records, activation and onboarding', custom: renderStaff, }, { id: 'audit', group: 'Administration', icon: 'ph-scroll', title: 'Audit Log', subtitle: 'Privileged actions recorded across the platform', panels: [{ title: 'Audit entries', path: '/Admin/v2/security/audit_logs.php' }], }, { id: 'errors', group: 'Administration', icon: 'ph-bug', title: 'Error Log', subtitle: 'Last errors reported by the mobile apps', panels: [{ title: 'Recent errors', path: '/Admin/error/error_list_last20.php' }], }, ]; const loadedModules = new Set(); function buildModules() { const menu = document.querySelector('.sidebar-menu'); const main = document.querySelector('.content-body'); if (!menu || !main) return; const groups = [...new Set(MODULES.map((m) => m.group))]; groups.forEach((group) => { const label = document.createElement('div'); label.className = 'menu-label'; label.textContent = t(group); menu.appendChild(label); MODULES.filter((m) => m.group === group).forEach((mod) => { const item = document.createElement('a'); item.className = 'nav-item'; item.dataset.view = `mod_${mod.id}`; item.dataset.module = mod.id; if (mod.superOnly) { item.setAttribute('data-requires-super', ''); item.hidden = true; } item.innerHTML = `${esc(t(mod.title))}`; menu.appendChild(item); const section = document.createElement('section'); section.className = 'page-view'; section.id = `mod_${mod.id}`; section.innerHTML = `
`; main.appendChild(section); section.querySelector('[data-reload]').addEventListener('click', () => loadModule(mod, true)); }); }); } async function loadModule(mod, force = false) { if (loadedModules.has(mod.id) && !force) return; loadedModules.add(mod.id); const host = $(`panels_${mod.id}`); if (mod.custom) { await mod.custom(host); return; } host.innerHTML = mod.panels.map((p) => `

${esc(t(p.title))}

${t('Loading…')}
`).join(''); await Promise.all(mod.panels.map(async (p) => { const body = host.querySelector(`[data-panel="${cssEscape(p.path)}"] .panel-body`); try { const payload = await api(p.path, { params: p.params || null }); renderPayload(body, payload); } catch (err) { if (handleApiError(err, mod.id)) return; body.innerHTML = `
${esc(err.message)}
`; } })); } // Endpoints that answer only for a specific record get an input rather than // an empty panel: they require a parameter, so eagerly calling them would // just render an error. function renderLookupTools(host, tools) { host.innerHTML = tools.map((tool, i) => `

${esc(tool.title)}

${t('Enter a value above to query this endpoint.')}
`).join(''); host.querySelectorAll('[data-lookup-run]').forEach((btn) => { const index = Number(btn.dataset.lookupRun); const tool = tools[index]; const input = host.querySelector(`[data-lookup-input="${index}"]`); const body = host.querySelector(`[data-lookup-body="${index}"]`); const run = async () => { const value = input.value.trim(); if (!value) return; body.innerHTML = `
${t('Querying…')}
`; try { const payload = await api(tool.path, { params: { [tool.field]: value } }); renderPayload(body, payload); } catch (err) { if (handleApiError(err, 'lookup')) return; body.innerHTML = `
${esc(err.message)}
`; toast(err.message, 'danger'); } }; btn.addEventListener('click', run); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') run(); }); }); } // ── Growth ─────────────────────────────────────────────────────────────── // growth.php returns one row per day that had signups — days with none are // simply absent. Plotting those rows directly would compress gaps and make // a quiet week look like steady growth, so the series is expanded to a // continuous 30-day axis with explicit zeros. function densifyDays(rows, valueKey, days = 30) { const byDate = new Map(); (rows || []).forEach((r) => byDate.set(String(r.date).slice(0, 10), Number(r[valueKey]) || 0)); const series = []; const today = new Date(); for (let i = days - 1; i >= 0; i--) { const d = new Date(today); d.setDate(today.getDate() - i); const iso = d.toISOString().slice(0, 10); series.push({ label: `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}`, value: byDate.get(iso) || 0, }); } return series; } const sumSeries = (series) => series.reduce((a, p) => a + p.value, 0); async function renderGrowth(host) { host.innerHTML = `
${t('Loading growth…')}
`; let payload; try { payload = await api('/Admin/v2/analytics/growth.php'); } catch (err) { if (handleApiError(err, 'growth')) return; host.innerHTML = `
${esc(err.message)}
`; return; } const passengers = densifyDays(payload?.passenger_daily, 'new_passengers'); const drivers = densifyDays(payload?.driver_daily, 'new_drivers'); const totals = payload?.totals || {}; const joinedP = sumSeries(passengers); const joinedD = sumSeries(drivers); const activeDays = passengers.filter((p, i) => p.value || drivers[i].value).length; host.innerHTML = `
${growthTile(fmtInt(totals.passengers), t('Passengers in total'), 'ph-users', 'warning')} ${growthTile(fmtInt(totals.drivers), t('Captains in total'), 'ph-steering-wheel', 'info')} ${growthTile(`+${fmtInt(joinedP)}`, t('Passengers joined · 30 days'), 'ph-user-plus', 'primary')} ${growthTile(`+${fmtInt(joinedD)}`, t('Captains joined · 30 days'), 'ph-user-plus', 'success')}

${t('Signups per day')} ${t('last 30 days')}

${t('Passengers')} ${t('Captains')}
${activeDays === 0 ? `
${t('Nobody signed up in the last 30 days.')}
` : `
${activeDays} / 30 ${lang === 'ar' ? 'يوماً بها تسجيل واحد على الأقل' : 'days had at least one signup'}
`}
`; drawMultiLine('growthChart', [ { label: t('Passengers'), color: '#6366f1', points: passengers }, { label: t('Captains'), color: '#10b981', points: drivers }, ]); } function growthTile(value, label, icon, tone) { return `
${esc(value)}
${esc(label)}
`; } // ── Advanced analytics ─────────────────────────────────────────────────── async function renderAnalytics(host) { host.innerHTML = `
${t('Revenue is counted from completed rides only.')}
${t('Loading revenue…')}
${t('Loading captain ranking…')}
`; // Revenue try { const rev = await api('/Admin/v2/analytics/revenue.php'); const daily = rev?.daily || []; const summary = rev?.summary || {}; const series = densifyDays(daily, 'total_revenue'); const rides = densifyDays(daily, 'total_rides'); $('anRevenue').innerHTML = `

${t('Revenue')} ${t('last 30 days')}

${summary.total_revenue_all == null ? '—' : fmtMoney(summary.total_revenue_all)}
${t('Total collected')}
${summary.total_profit_all == null ? '—' : fmtMoney(summary.total_profit_all)}
${t('Platform share')}
${summary.avg_ride_price == null ? '—' : fmtMoney(summary.avg_ride_price)}
${t('Average fare')}
${fmtInt(sumSeries(rides))}
${t('Completed rides')}
${sumSeries(series) === 0 ? `
${t('No completed rides carried a fare in this period.')}
` : ''}`; drawMultiLine('revenueTrend', [{ label: t('Revenue'), color: '#06b6d4', points: series }]); } catch (err) { if (handleApiError(err, 'analytics-revenue')) return; $('anRevenue').innerHTML = `
${esc(err.message)}
`; } // Captain ranking try { const ranking = await api('/Admin/v2/analytics/driver_ranking.php'); const body = $('anRanking'); body.innerHTML = `

${t('Captain ranking')}

`; renderPayload(body.querySelector('.panel-body'), ranking); } catch (err) { if (handleApiError(err, 'analytics-ranking')) return; $('anRanking').innerHTML = `
${esc(err.message)}
`; } } // ── Transit organisations ──────────────────────────────────────────────── // A list of counts is not actionable on its own: an operator needs to open // an organisation, see its routes and trips, adjust the contract, and manage // who administers it. const ORG_TYPES = ['university', 'school', 'hotel', 'company', 'transporter']; const CONTRACT_STATES = ['trial', 'active', 'suspended', 'expired']; async function renderTransitOrgs(host) { host.innerHTML = `
${t('Loading organisations…')}
`; $('orgNewBtn').addEventListener('click', () => openOrgForm(null, host)); bindSearch($('orgSearch'), $('orgSearchBtn'), (term) => loadOrgs(host, term), () => loadOrgs(host, '')); loadOrgs(host, ''); } async function loadOrgs(host, search) { const panel = $('orgList'); panel.innerHTML = `
${t('Loading organisations…')}
`; try { const payload = await api('/Admin/transit/org/list.php', { params: { search } }); const orgs = payload?.orgs || []; const total = payload?.pagination?.total ?? orgs.length; if (!orgs.length) { panel.innerHTML = `
${t('No organisations registered yet.')}
`; return; } panel.innerHTML = `

${t('Organisations')} ${fmtInt(total)} ${t('total')}

${orgs.map((o, i) => ` `).join('')}
${t('Name')}${t('Type')}${t('City')}${t('Contract')} ${t('Captains')}${t('Vehicles')}${t('Routes')}${t('Enrolled')}
${esc(o.name_ar || o.name_en || '—')} ${esc(humanize(o.type || '—'))} ${esc(o.city || '—')} ${esc(humanize(o.contract_status || 'unknown'))} ${fmtInt(o.drivers_count)} ${fmtInt(o.vehicles_count)} ${fmtInt(o.active_routes)} ${fmtInt(o.active_enrollments)}
`; panel.querySelectorAll('[data-org]').forEach((row) => row.addEventListener('click', (e) => { if (e.target.closest('[data-org-edit]')) return; openOrgDetails(row.dataset.org, host); })); panel.querySelectorAll('[data-org-edit]').forEach((btn) => btn.addEventListener('click', () => openOrgForm(orgs[Number(btn.dataset.orgEdit)], host))); } catch (err) { if (handleApiError(err, 'transit-orgs')) return; panel.innerHTML = `
${esc(err.message)}
`; } } async function openOrgDetails(orgId, host) { const body = $('modalBodyContent'); body.innerHTML = `
${t('Loading…')}
`; $('detailsModal').classList.add('active'); try { const payload = await api('/Admin/transit/org/details.php', { params: { org_id: orgId } }); const org = payload?.org || {}; const counts = payload?.counts || {}; const routes = payload?.routes || []; const trips = payload?.trips || []; body.innerHTML = `
${Object.entries(counts).map(([k, v]) => `
${esc(formatValue(v, k))}
${esc(humanize(k))}
`).join('')}

${t('Routes')} (${routes.length})

${routes.length ? `
${routes.map((r) => `
${esc(r.name_ar || r.name_en || `#${r.id}`)} ${esc(humanize(r.status || ''))}
`).join('')}
` : `
${t('No routes yet.')}
`}

${t('Recent trips')} (${trips.length})

${t('Administrators')}

${t('Loading…')}
`; renderPayload($('orgTrips'), trips); loadOrgAdmins(orgId); $('orgAdminAdd').addEventListener('click', () => addOrgAdmin(orgId)); } catch (err) { if (handleApiError(err, 'org-details')) return; body.innerHTML = `
${esc(err.message)}
`; } } async function loadOrgAdmins(orgId) { const panel = $('orgAdmins'); if (!panel) return; try { const payload = await api('/Admin/transit/org/admins_list.php', { params: { org_id: orgId } }); const admins = payload?.admins || []; if (!admins.length) { panel.innerHTML = `
${t('No administrators yet.')}
`; return; } panel.innerHTML = `
${admins.map((a) => `
${esc(a.name || '—')} ${esc(humanize(a.role || ''))}
`).join('')}
`; panel.querySelectorAll('[data-admin-toggle]').forEach((btn) => btn.addEventListener('click', () => toggleOrgAdmin(btn.dataset.adminToggle, Number(btn.dataset.active) ? 0 : 1, orgId))); } catch (err) { if (handleApiError(err, 'org-admins')) return; panel.innerHTML = `
${esc(err.message)}
`; } } async function addOrgAdmin(orgId) { const name = $('orgAdminName').value.trim(); const phone = $('orgAdminPhone').value.trim(); if (!name || !phone) { toast('Enter both a name and a phone number.', 'warning'); return; } if (!confirm(`Give ${name} (${phone}) administrator access to this organisation?`)) return; try { await api('/Admin/transit/org/admin_add.php', { params: { org_id: orgId, name, phone } }); toast('Administrator added.', 'success'); $('orgAdminName').value = ''; $('orgAdminPhone').value = ''; loadOrgAdmins(orgId); } catch (err) { if (!handleApiError(err, 'org-admin-add')) toast(err.message, 'danger'); } } async function toggleOrgAdmin(adminId, isActive, orgId) { if (!confirm(isActive ? 'Enable this administrator?' : 'Disable this administrator?')) return; try { await api('/Admin/transit/org/admin_toggle.php', { params: { admin_id: adminId, is_active: isActive } }); toast(isActive ? 'Administrator enabled.' : 'Administrator disabled.', 'success'); loadOrgAdmins(orgId); } catch (err) { if (!handleApiError(err, 'org-admin-toggle')) toast(err.message, 'danger'); } } // create.php needs the founding administrator; update.php only takes the // fields it knows, so the form differs between the two modes. function openOrgForm(org, host) { const editing = !!org; const body = $('modalBodyContent'); body.innerHTML = `
${editing ? '' : ` `} ${editing ? ` ` : ''}
`; $('detailsModal').classList.add('active'); $('orgSave').addEventListener('click', () => saveOrg(org, host)); } async function saveOrg(org, host) { const editing = !!org; try { if (editing) { const params = { org_id: org.id, city: $('orgCity').value.trim(), contact_email: $('orgEmail').value.trim(), website: $('orgWebsite').value.trim(), contract_status: $('orgContract').value, trial_ends_at: $('orgTrialEnds').value, }; if (params.contract_status !== org.contract_status && !confirm(`Change the contract from "${org.contract_status}" to "${params.contract_status}"? ` + 'Suspending stops the organisation using the service.')) return; await api('/Admin/transit/org/update.php', { params }); toast('Organisation updated.', 'success'); } else { const params = { type: $('orgType').value, name_ar: $('orgNameAr').value.trim(), name_en: $('orgNameEn').value.trim(), country: $('orgCountry').value.trim().toUpperCase(), city: $('orgCity').value.trim(), admin_name: $('orgAdminNameNew').value.trim(), admin_phone: $('orgAdminPhoneNew').value.trim(), }; if (!params.name_ar || !params.admin_name || !params.admin_phone) { toast('Name, founding admin name and phone are required.', 'warning'); return; } await api('/Admin/transit/org/create.php', { params }); toast('Organisation created.', 'success'); } closeModal(); loadOrgs(host, $('orgSearch')?.value.trim() || ''); } catch (err) { if (!handleApiError(err, 'org-save')) toast(err.message, 'danger'); } } // ── Blacklist & removal ────────────────────────────────────────────────── // Deletion here is a real DELETE against passengers/driver — the account and // its login are gone. The console therefore demands the phone number be // retyped, on top of the confirm dialog. async function renderBlacklist(host) { host.innerHTML = `
${t('Removal is permanent.')} ${lang === 'ar' ? 'يتم حذف حساب السائق وإضافة رقم الهاتف إلى القائمة السوداء حتى لا يتمكن من التسجيل مرة أخرى. لا يمكن التراجع عن هذا الإجراء — فقط رفع الحظر، الذي لا يُعيد الحساب المحذوف.' : 'The account row is deleted and the phone number is added to the blacklist so it cannot register again. There is no undo — only lifting the block, which does not restore the deleted account.'}
${t('Loading blacklist…')}

${lang === 'ar' ? 'حذف و加入 القائمة السوداء' : 'Remove & blacklist'}

${t('Lift a block')}

${lang === 'ar' ? 'يزيل رقم الهاتف من القائمة السوداء حتى يتمكن من التسجيل مرة أخرى. لا يُعيد الحساب المحذوف.' : 'Removes the phone number from the blacklist so it can register again. It does not restore a deleted account.'}

`; $('rmSubmit').addEventListener('click', () => removeAndBlacklist(host)); $('ubSubmit').addEventListener('click', () => liftBlock(host)); loadBlacklist(); } async function loadBlacklist() { const panel = $('blacklistCurrent'); try { const payload = await api('/Admin/v2/quality/blacklist_manager.php', { params: { action_type: 'get_all' }, }); panel.innerHTML = `

${t('Currently blocked')}

`; renderPayload(panel.querySelector('.panel-body'), payload); } catch (err) { if (handleApiError(err, 'blacklist')) return; panel.innerHTML = `

${t('Currently blocked')}

${esc(err.message)}
`; } } async function removeAndBlacklist(host) { const type = $('rmType').value; const phone = $('rmPhone').value.trim(); const id = $('rmId').value.trim(); const reason = $('rmReason').value.trim(); const confirmPhone = $('rmConfirmPhone').value.trim(); const status = $('rmStatus'); const label = type === 'driver' ? 'captain' : 'passenger'; if (!phone) { toast('Enter the phone number of the account to remove.', 'warning'); return; } if (confirmPhone !== phone) { toast('The retyped phone does not match — nothing was deleted.', 'danger'); return; } if (type === 'driver' && !id) { toast('Removing a captain requires their account ID.', 'warning'); return; } if (!confirm( `Permanently delete the ${label} on ${phone} and blacklist that number?\n\n` + `${reason ? `Reason: ${reason}\n\n` : ''}` + 'The account is deleted from the database. This cannot be undone.' )) return; busy($('rmSubmit'), true, 'Deleting…'); status.textContent = ''; try { if (type === 'driver') { await api('/Admin/driver/deleteCaptain.php', { params: { driver_id: id, phone, reason } }); } else { await api('/Admin/passenger/admin_delete_and_blacklist_passenger.php', { params: { id, phone, reason }, }); } status.textContent = `Removed and blacklisted ${phone}`; toast(`${label === 'captain' ? 'Captain' : 'Passenger'} removed and blacklisted.`, 'success'); ['rmPhone', 'rmId', 'rmReason', 'rmConfirmPhone'].forEach((f) => { $(f).value = ''; }); loadBlacklist(); } catch (err) { if (!handleApiError(err, 'remove-account')) toast(err.message, 'danger'); } finally { busy($('rmSubmit'), false, 'Delete and blacklist'); } } async function liftBlock() { const type = $('ubType').value; const phone = $('ubPhone').value.trim(); const status = $('ubStatus'); if (!phone) { toast('Enter the blocked phone number.', 'warning'); return; } if (!confirm(`Lift the block on ${phone}? They will be able to register again.`)) return; busy($('ubSubmit'), true, 'Working…'); try { const path = type === 'driver' ? '/Admin/driver/remove_from_blacklist.php' : '/Admin/passenger/admin_unblacklist.php'; await api(path, { params: { phone } }); status.textContent = `Block lifted for ${phone}`; toast('Block lifted.', 'success'); $('ubPhone').value = ''; loadBlacklist(); } catch (err) { if (!handleApiError(err, 'lift-block')) toast(err.message, 'danger'); } finally { busy($('ubSubmit'), false, 'Lift block'); } } // ── Campaign launcher ──────────────────────────────────────────────────── // trigger_campaign.php asks Gemini for a campaign, writes a promo code valid // for seven days, and pushes it to every passenger in the country. The // preview (dry_run=1) runs the same analysis and stops before both. function renderCampaigns(host) { host.innerHTML = `
${lang === 'ar' ? 'الإطلاق ينشئ كود خصم حقيقي ويخبر كل راكب في الدولة المحددة.' : 'Launching creates a real discount code and notifies every passenger in the selected country.'} ${lang === 'ar' ? 'يبقى كود الخصم صالحاً لمدة سبعة أيام. عاين دائماً أولاً.' : 'The promo stays valid for seven days. Always preview first.'}

${t('Campaign parameters')}

${t('Preview first to enable launching.')}
${t('No analysis run yet.')}
${t('Loading campaign history…')}
`; $('cmpPreview').addEventListener('click', () => runCampaign(true)); $('cmpLaunch').addEventListener('click', () => runCampaign(false)); loadCampaignLog(); } // A launch is only allowed for parameters that were previewed, so an edit // after previewing disarms the button again. let previewedCampaign = null; function campaignParams() { return { country_code: $('cmpCountry').value, region_name: $('cmpRegion').value.trim(), siro_base_price: $('cmpBasePrice').value.trim(), }; } async function runCampaign(isPreview) { const params = campaignParams(); const signature = JSON.stringify(params); if (!isPreview) { if (signature !== previewedCampaign) { toast('Parameters changed since the preview — preview again before launching.', 'warning'); $('cmpLaunch').disabled = true; return; } if (!confirm( `Launch this campaign in ${params.country_code}?\n\n` + 'It creates a discount code valid for 7 days and pushes a notification to every passenger there.\n\n' + 'This cannot be undone.' )) return; } const btn = isPreview ? $('cmpPreview') : $('cmpLaunch'); busy(btn, true, isPreview ? t('Analysing…') : t('Launching…')); $('cmpResult').innerHTML = `
${t('Running market analysis…')}
`; try { const payload = await api('/Admin/marketing/trigger_campaign.php', { params: isPreview ? { ...params, dry_run: '1' } : params, }); $('cmpResult').innerHTML = `

${isPreview ? t('Preview') : t('Launch result')}

`; renderPayload($('cmpResult').querySelector('.panel-body'), payload); if (isPreview) { const opportunity = payload?.ai_analysis?.opportunity_detected ?? payload?.campaign_created; previewedCampaign = signature; $('cmpLaunch').disabled = false; $('cmpStatus').textContent = opportunity === false ? 'The AI found no opportunity — launching would still send.' : `Previewed ${params.country_code}. Launch is now enabled.`; } else { previewedCampaign = null; $('cmpLaunch').disabled = true; $('cmpStatus').textContent = `Launched at ${new Date().toLocaleTimeString()}`; toast('Campaign dispatched.', 'success'); loadCampaignLog(); } } catch (err) { if (!handleApiError(err, 'campaign')) { $('cmpResult').innerHTML = `
${esc(err.message)}
`; toast(err.message, 'danger'); } } finally { busy(btn, false, isPreview ? 'Preview' : 'Launch campaign'); // busy() clears `disabled`, so re-apply the arming rule afterwards: // launching stays locked until the current parameters are previewed. $('cmpLaunch').disabled = previewedCampaign !== JSON.stringify(campaignParams()); } } async function loadCampaignLog() { const panel = $('cmpLog'); try { const payload = await api('/Admin/marketing/get_campaigns_log.php'); panel.innerHTML = `

${t('Campaign history')}

`; renderPayload(panel.querySelector('.panel-body'), payload); } catch (err) { if (handleApiError(err, 'campaign-log')) return; panel.innerHTML = `

${t('Campaign history')}

${esc(err.message)}
`; } } // ── App versions ───────────────────────────────────────────────────────── async function renderAppVersions(host) { host.innerHTML = `
${t('Loading package versions…')}
`; let packages = []; try { packages = normaliseRows(await api('/serviceapp/getPackages.php')); } catch (err) { if (handleApiError(err, 'packages')) return; host.innerHTML = `
${esc(err.message)}
`; return; } if (!packages.length) { host.innerHTML = `
${t('No package rows configured.')}
`; return; } host.innerHTML = `
${lang === 'ar' ? 'كل تطبيق يقارن إصداره بهذا الرقم عند التشغيل. رفعه يجبر كل مستخدم على التحديث قبل المتابعة.' : 'Each app compares its own build against this number on launch. Raising it can force every user of that app to update before they can continue.'}
${packages.map((pkg, index) => `

${esc(pkg.name || pkg.packageName || pkg.app_name || `Package #${pkg.id}`)} #${esc(pkg.id)}

`).join('')}`; host.querySelectorAll('[data-pkg-save]').forEach((btn) => btn.addEventListener('click', () => saveVersion(packages[Number(btn.dataset.pkgSave)], Number(btn.dataset.pkgSave), host))); } async function saveVersion(pkg, index, host) { const input = host.querySelector(`[data-pkg-version="${index}"]`); const version = input.value.trim(); if (version === String(pkg.version ?? '')) { toast('Version unchanged.', 'info'); return; } // Mirrors the server-side check so a typo is caught before the request. if (!/^\d+(\.\d+){0,3}$/.test(version)) { toast('Use digits separated by dots, e.g. 1.4.2', 'warning'); return; } if (!confirm( `Set ${pkg.name || `package #${pkg.id}`} to version ${version} (was ${pkg.version ?? '—'})?\n\n` + 'Users on an older build may be prompted or forced to update.' )) return; try { await api('/serviceapp/updatePackages.php', { params: { id: pkg.id, version } }); toast(`Version set to ${version}.`, 'success'); renderAppVersions(host); } catch (err) { if (!handleApiError(err, 'package-save')) toast(err.message, 'danger'); } } // ── Driver document review ─────────────────────────────────────────────── // The list is paged server-side (limit/offset). Activation posts // status=active to Admin/driver/updateDriverFromAdmin.php, exactly as the // Flutter DriverDocsController does. const DOCS_PAGE_SIZE = 15; let docsOffset = 0; async function renderDriverDocs(host, offset = 0) { docsOffset = offset; host.innerHTML = `
${t('Loading captains awaiting review…')}
`; let drivers = []; try { const payload = await api('/auth/driver/drivers_pending_list.php', { params: { limit: DOCS_PAGE_SIZE, offset }, }); drivers = normaliseRows(payload); } catch (err) { if (handleApiError(err, 'driver-docs')) return; host.innerHTML = `
${esc(err.message)}
`; return; } if (!drivers.length && offset === 0) { host.innerHTML = `
${t('No captains are awaiting document review.')}
`; return; } host.innerHTML = `

${t('Awaiting review')} ${lang === 'ar' ? `عرض ${drivers.length} من #${offset + 1}` : `showing ${drivers.length} from #${offset + 1}`}

${drivers.map((d) => ` `).join('')}
IDNamePhone
#${esc(d.id)} ${esc(`${d.first_name || ''} ${d.last_name || ''}`.trim() || 'Unnamed')} ${esc(maskPhone(d.phone))}
${t('Pick a captain above to inspect their documents.')}
`; $('docsPrev')?.addEventListener('click', () => renderDriverDocs(host, Math.max(0, offset - DOCS_PAGE_SIZE))); $('docsNext')?.addEventListener('click', () => renderDriverDocs(host, offset + DOCS_PAGE_SIZE)); host.querySelectorAll('[data-review]').forEach((btn) => btn.addEventListener('click', () => showDriverDocs(btn.dataset.review, host))); } async function showDriverDocs(driverId, host) { const panel = $('docsDetail'); panel.innerHTML = '
Loading documents…
'; let driver = {}; let documents = []; try { // Sent as a POST body: filterRequest() ignores query strings, so the // mobile app's GET "?id=" form never reaches this endpoint's $driverId. const payload = await api('/auth/driver/driver_details.php', { params: { id: driverId } }); driver = payload?.driver || {}; documents = payload?.documents || []; } catch (err) { if (handleApiError(err, 'driver-details')) return; panel.innerHTML = `
${esc(err.message)}
`; return; } const facts = Object.entries(driver) .filter(([k, v]) => !/token|password|fingerprint/i.test(k) && v !== null && v !== '') .slice(0, 18); panel.innerHTML = `

${esc(`${driver.first_name || ''} ${driver.last_name || ''}`.trim() || `Captain #${driverId}`)} #${esc(driverId)} · ${esc(driver.status || 'unknown')}

${lang === 'ar' ? 'الوثائق' : 'Documents'} (${documents.length})

${documents.length ? `
${documents.map((doc) => `
${doc.link ? ` ${esc(doc.doc_type || 'document')} ` : `
${lang === 'ar' ? 'لا ملف مرتبط' : 'no file linked'}
`}
${esc(humanize(doc.doc_type || 'document'))} ${esc(doc.image_name || '—')}
`).join('')}
` : `
${lang === 'ar' ? 'هذا الكابتن لم يُرسل أي وثائق — الاعتماد الآن يعني تفعيل حساب غير موثّق.' : 'This captain has uploaded no documents — approving now would activate an unverified account.'}
`}

${lang === 'ar' ? 'السجل' : 'Record'}

${facts.map(([k, v]) => `
${esc(humanize(k))} ${esc(/phone/i.test(k) ? maskPhone(v) : formatValue(v, k))}
`).join('')}
`; panel.querySelector('[data-approve-driver]').addEventListener('click', () => approveDriver(driverId, driver, documents.length, host)); } async function approveDriver(driverId, driver, docCount, host) { const name = `${driver.first_name || ''} ${driver.last_name || ''}`.trim() || `#${driverId}`; const warning = docCount === 0 ? '\n\nWARNING: no documents are on file for this captain.' : `\n\n${docCount} document(s) reviewed.`; if (!confirm(`Activate captain ${name}?${warning}\n\nThey will be able to accept rides immediately.`)) return; try { await api('/Admin/driver/updateDriverFromAdmin.php', { params: { id: driverId, status: 'active' }, }); toast(`Captain ${name} activated.`, 'success'); renderDriverDocs(host, docsOffset); } catch (err) { if (!handleApiError(err, 'driver-approve')) toast(err.message, 'danger'); } } // ── Staff management ───────────────────────────────────────────────────── async function renderStaff(host) { host.innerHTML = `
${t('Loading pending accounts…')}
${t('Loading employees…')}

${t('Add a staff account')}

${lang === 'ar' ? 'يُنشئ حساب دخول لأدوات سير للمشرفين. اختر كلمة المرور مع العضو الجديد، أو دعهم يغيّروها عند أول تسجيل دخول —她是 مشفرة ولا يمكن قراءتها.' : 'Creates a login for the Siro admin tools. Choose the password with the new member present, or have them change it at first sign-in — it is stored hashed and cannot be read back.'}

`; $('staffAdd').addEventListener('click', () => addStaff(host)); loadStaffPending(host); loadEmployees(); } async function loadStaffPending(host) { const panel = $('staffPending'); try { const payload = await api('/Admin/Staff/pending.php'); const rows = payload?.data || []; const sources = payload?.sources || {}; const notes = Object.entries(sources) .filter(([, state]) => state !== 'ok') .map(([name, state]) => `
${esc(humanize(name))}: ${esc(state)}
`) .join(''); panel.innerHTML = `

${t('Pending activation')}

${notes} ${rows.length ? `
${rows.map((r) => ` `).join('')}
${t('ID')}${t('Name')}${t('Phone')}${t('Type')}${lang === 'ar' ? 'طُلب' : 'Requested'}
#${esc(r.id)} ${esc(r.name || '—')} ${esc(maskPhone(r.phone))} ${esc(r.type)} ${esc(fmtDate(r.created_at, true))}
` : (notes ? '' : '
' + (lang === 'ar' ? 'لا توجد حسابات بانتظار التفعيل.' : 'No accounts are waiting for activation.') + '
')}`; panel.querySelectorAll('[data-activate]').forEach((btn) => btn.addEventListener('click', () => activateStaff(btn.dataset.activate, btn.dataset.type, host))); } catch (err) { if (handleApiError(err, 'staff-pending')) return; panel.innerHTML = `
${esc(err.message)}
`; } } async function loadEmployees() { const panel = $('staffList'); try { const payload = await api('/Admin/employee/get.php'); panel.innerHTML = `

${t('Employees')}

`; renderPayload(panel.querySelector('.panel-body'), payload); } catch (err) { if (handleApiError(err, 'employees')) return; panel.innerHTML = `

${t('Employees')}

${esc(err.message)}
`; } } async function activateStaff(userId, type, host) { if (!confirm(`Activate ${type} account #${userId}? They will be able to sign in immediately.`)) return; try { await api('/Admin/Staff/activate.php', { params: { user_id: userId, type } }); toast(`Account #${userId} activated.`, 'success'); loadStaffPending(host); } catch (err) { if (!handleApiError(err, 'staff-activate')) toast(err.message, 'danger'); } } async function addStaff(host) { const role = $('staffRole').value; const name = $('staffName').value.trim(); const phone = $('staffPhone').value.trim(); const email = $('staffEmail').value.trim(); const password = $('staffPassword').value; const country = $('staffCountry').value.trim() || 'Jordan'; const status = $('staffStatus'); if (!name || !password) { toast('Name and password are required.', 'warning'); return; } if (password.length < 8) { toast('Use a password of at least 8 characters.', 'warning'); return; } if (role === 'admin' && !isSuperAdmin()) { toast('Only a super admin can create administrator accounts.', 'warning'); return; } const roleLabel = role === 'admin' ? 'ADMINISTRATOR' : 'customer service'; if (!confirm( `Create a ${roleLabel} account for "${name}"?\n\n` + `Phone: ${phone || '—'}\nEmail: ${email || '—'}\n\n` + (role === 'admin' ? 'Administrators can see and change platform data.' : 'Customer service staff can view operational data.') )) return; busy($('staffAdd'), true, 'Creating…'); status.textContent = ''; try { await api('/Admin/Staff/add.php', { params: { name, phone, email, password, role, country }, }); status.textContent = `Created ${roleLabel} account for ${name}`; toast('Staff account created.', 'success'); ['staffName', 'staffPhone', 'staffEmail', 'staffPassword'].forEach((id) => { $(id).value = ''; }); loadStaffPending(host); } catch (err) { if (!handleApiError(err, 'staff-add')) toast(`Could not create account: ${err.message}`, 'danger'); } finally { busy($('staffAdd'), false, 'Create account'); } } // ── Route approvals ────────────────────────────────────────────────────── // transit/route/approve.php accepts approve | suspend | reject and refuses a // no-op transition, so each decision is confirmed against the route's stops. async function renderRouteApprovals(host) { host.innerHTML = `
${t('Loading draft routes…')}
`; let routes = []; try { const payload = await api('/Admin/transit/route/pending.php'); routes = payload?.routes || []; } catch (err) { if (handleApiError(err, 'routes')) return; host.innerHTML = `
${esc(err.message)}
`; return; } if (!routes.length) { host.innerHTML = `
${t('No routes are waiting for approval.')}
`; return; } host.innerHTML = routes.map((route, index) => `

${esc(route.name_ar || route.name_en || (lang === 'ar' ? 'مسار بدون اسم' : 'Unnamed route'))} #${esc(route.id)} · ${esc(route.org_name || (lang === 'ar' ? 'مؤسسة غير معروفة' : 'unknown organisation'))}

${esc(route.direction || '—')}
${t('Direction')}
${fmtNum(route.distance_km)} km
${t('Distance')}
${fmtInt(route.duration_min)} ${lang === 'ar' ? 'دقيقة' : 'min'}
${t('Duration')}
${fmtInt(route.stops_count)}
${t('Stops')}
${esc(route.country || '—')}
${t('Country')}
${esc(fmtDate(route.created_at, true))}
${t('Submitted')}

${t('Stops')}

${(route.stops || []).length ? `
    ${route.stops.map((s) => `
  1. ${esc(s.name_ar || (lang === 'ar' ? 'محطة بدون اسم' : 'Unnamed stop'))} ${Number(s.is_major) ? `${t('major')}` : ''} ${esc(shortCoord(`${s.latitude},${s.longitude}`))}
  2. `).join('')}
` : `
${t('This route has no stops recorded.')}
`}
`).join(''); host.querySelectorAll('[data-route-action]').forEach((btn) => btn.addEventListener('click', () => decideRoute(routes[Number(btn.dataset.route)], btn.dataset.routeAction, host))); } async function decideRoute(route, action, host) { const verb = action === 'approve' ? 'approve' : 'reject'; const consequence = action === 'approve' ? 'The route goes live and passengers can ride it.' : 'The organisation will have to resubmit the route.'; if (!confirm( `${verb === 'approve' ? 'Approve' : 'Reject'} route "${route.name_ar || route.id}" ` + `from ${route.org_name || 'this organisation'}?\n\n` + `${fmtInt(route.stops_count)} stops · ${fmtNum(route.distance_km)} km\n\n${consequence}` )) return; try { await api('/Admin/transit/route/approve.php', { params: { route_id: route.id, action }, }); toast(`Route #${route.id} ${verb}ed.`, 'success'); renderRouteApprovals(host); } catch (err) { if (!handleApiError(err, 'route-decision')) toast(err.message, 'danger'); } } // ── Broadcast notifications ────────────────────────────────────────────── // Goes through Admin/notifications/broadcast.php, never the internal FCM // endpoint: the browser must not hold the internal API key. function renderBroadcast(host) { host.innerHTML = `
${lang === 'ar' ? 'هذا يصل لكل الأجهزة دفعة واحدة ولا يمكن استرجاعه.' : 'This reaches every device at once and cannot be recalled.'} ${lang === 'ar' ? 'يُسجَّل الرسالة في سجل العمليات ضد حسابك.' : 'The message is recorded in the audit log against your account.'}

${t('Compose')}

${t('Preview')}

Siro
${t('Notification title')}
${t('Message text')}
`; const title = $('bcTitle'); const body = $('bcBody'); const sync = () => { $('bcPreviewTitle').textContent = title.value.trim() || t('Notification title'); $('bcPreviewBody').textContent = body.value.trim() || t('Message text'); }; title.addEventListener('input', sync); body.addEventListener('input', sync); $('bcSend').addEventListener('click', () => sendBroadcast(host)); } async function sendBroadcast(host) { if (!isSuperAdmin()) { toast('Broadcasting is restricted to super admins.', 'warning'); return; } const audience = $('bcAudience').value; const title = $('bcTitle').value.trim(); const body = $('bcBody').value.trim(); const status = $('bcStatus'); const audienceLabel = audience === 'drivers' ? 'every captain' : 'every passenger'; if (!title || !body) { toast('Enter both a title and a message.', 'warning'); return; } if (!confirm( `Send this notification to ${audienceLabel} on the platform?\n\n` + `${title}\n${body}\n\n` + 'It is delivered immediately and cannot be recalled.' )) return; busy($('bcSend'), true, t('Sending…')); status.textContent = ''; try { const result = await api('/Admin/notifications/broadcast.php', { params: { audience, title, body }, }); status.textContent = `Sent to ${audienceLabel} at ${new Date().toLocaleTimeString()}`; toast(`Notification delivered to ${audienceLabel}.`, 'success'); $('bcTitle').value = ''; $('bcBody').value = ''; $('bcPreviewTitle').textContent = 'Notification title'; $('bcPreviewBody').textContent = 'Message text'; console.info('[broadcast]', result); } catch (err) { if (!handleApiError(err, 'broadcast')) toast(`Send failed: ${err.message}`, 'danger'); } finally { busy($('bcSend'), false, 'Review & send'); } } // ── Kazan tariff editor ────────────────────────────────────────────────── // Only these columns are accepted by ride/kazan/update.php; anything else // sent would be silently dropped, so the form mirrors that list exactly. const TARIFF_FIELDS = [ { key: 'kazanPercent', label: 'Platform commission', hint: '% taken by Siro' }, { key: 'fuelPrice', label: 'Fuel price' }, { key: 'currency', label: 'Currency', type: 'text' }, { key: 'normalMinPrice', label: 'Minimum fare — normal' }, { key: 'peakMinPrice', label: 'Minimum fare — peak' }, { key: 'lateMinPrice', label: 'Minimum fare — late night' }, { key: 'fixedPrice', label: 'Fixed price' }, { key: 'speedPrice', label: 'Speed' }, { key: 'comfortPrice', label: 'Comfort' }, { key: 'ladyPrice', label: 'Lady' }, { key: 'electricPrice', label: 'Electric' }, { key: 'vanPrice', label: 'Van' }, { key: 'deliveryPrice', label: 'Delivery' }, { key: 'mishwarVipPrice', label: 'Mishwar VIP' }, { key: 'awfarPrice', label: 'Awfar' }, ]; let tariffRows = []; async function renderTariffEditor(host) { host.innerHTML = `
${t('Loading tariff…')}
`; try { const payload = await api('/ride/kazan/get.php'); tariffRows = Array.isArray(payload) ? payload : normaliseRows(payload); } catch (err) { if (handleApiError(err, 'tariff')) return; host.innerHTML = `
${esc(err.message)}
`; return; } if (!tariffRows.length) { host.innerHTML = `
${lang === 'ar' ? 'لا توجد صفوف تعرفة معدّة.' : 'No tariff rows configured.'}
`; return; } const readOnly = !isSuperAdmin(); host.innerHTML = ` ${readOnly ? `
${lang === 'ar' ? 'أنت مسجّل كمدير، لذا تُعرض التعرفة للقراءة فقط. فقط المدير العام يمكنه تغيير الأسعار.' : 'You are signed in as an admin, so the tariff is shown read-only. Only a super admin can change prices.'}
` : `
${lang === 'ar' ? 'هذه القيم حيّة. الحفظ يغيّر ما يدفعه كل راكب من الرحلة التالية فصاعداً. يُسجَّل التغيير في سجل العمليات ضد حسابك.' : 'These values are live. Saving changes what every passenger is charged from the next ride onwards. Changes are recorded in the audit log against your account.'}
`} ${tariffRows.map((row, index) => tariffCard(row, index, readOnly)).join('')}`; if (readOnly) return; host.querySelectorAll('[data-tariff-save]').forEach((btn) => btn.addEventListener('click', () => saveTariff(Number(btn.dataset.tariffSave), host))); host.querySelectorAll('[data-tariff-reset]').forEach((btn) => btn.addEventListener('click', () => renderTariffEditor(host))); } function tariffCard(row, index, readOnly) { const fields = TARIFF_FIELDS.filter((f) => row[f.key] !== undefined); return `

${esc(row.country || 'Tariff')} row #${esc(row.id)}

${readOnly ? '' : `
`}
${fields.map((f) => ` `).join('')}
`; } async function saveTariff(index, host) { if (!isSuperAdmin()) { toast('Only a super admin can change pricing.', 'warning'); return; } const row = tariffRows[index]; const inputs = host.querySelectorAll(`[data-tariff-input="${index}"]`); const changes = {}; inputs.forEach((input) => { const field = input.dataset.field; const current = String(row[field] ?? ''); const next = input.value.trim(); if (next !== current) changes[field] = next; }); if (!Object.keys(changes).length) { toast('Nothing changed on this tariff row.', 'info'); return; } const summary = Object.entries(changes) .map(([field, value]) => { const label = TARIFF_FIELDS.find((f) => f.key === field)?.label || field; return `• ${label}: ${row[field] ?? '—'} → ${value}`; }) .join('\n'); const confirmed = confirm( `Apply these pricing changes to "${row.country || 'tariff'}" (row #${row.id})?\n\n` + `${summary}\n\n` + 'This takes effect immediately for passengers.' ); if (!confirmed) return; try { await api('/ride/kazan/update.php', { params: { id: row.id, adminId: session?.id ?? '', ...changes }, }); toast('Tariff updated and recorded in the audit log.', 'success'); renderTariffEditor(host); } catch (err) { if (!handleApiError(err, 'tariff-save')) toast(`Update failed: ${err.message}`, 'danger'); } } function cssEscape(value) { return String(value).replace(/["\\]/g, '\\$&'); } // Renders whatever the endpoint returned: scalars become KPI tiles, arrays // of objects become tables, and nested objects recurse under their key. function renderPayload(host, payload, depth = 0) { host.innerHTML = ''; host.appendChild(buildNode(payload, depth)); } function buildNode(payload, depth) { const frag = document.createDocumentFragment(); if (payload === null || payload === undefined || payload === '') { frag.appendChild(msgNode(t('The endpoint responded successfully but returned no data yet.'))); return frag; } if (Array.isArray(payload)) { frag.appendChild(payload.length ? buildTable(payload) : msgNode(t('No records recorded for this yet — the table is empty in the database.'))); return frag; } if (typeof payload !== 'object') { frag.appendChild(msgNode(String(payload))); return frag; } const scalars = []; const nested = []; Object.entries(payload).forEach(([key, value]) => { if (value !== null && typeof value === 'object') nested.push([key, value]); else scalars.push([key, value]); }); if (scalars.length) { const grid = document.createElement('div'); grid.className = 'kpi-tiles'; grid.innerHTML = scalars.map(([k, v]) => `
${esc(/status|state/i.test(k) ? labelStatus(v) : formatValue(v, k))}
${esc(humanize(k))}
`).join(''); frag.appendChild(grid); } nested.forEach(([key, value]) => { const wrap = document.createElement('div'); wrap.className = 'sub-panel'; const heading = document.createElement('h4'); heading.className = 'sub-panel-title'; heading.textContent = humanize(key); wrap.appendChild(heading); wrap.appendChild(buildNode(value, depth + 1)); frag.appendChild(wrap); }); return frag; } function buildTable(rows) { const objects = rows.every((r) => r && typeof r === 'object' && !Array.isArray(r)); if (!objects) { const list = document.createElement('div'); list.className = 'mini-list'; list.innerHTML = rows.map((r) => `
${esc(String(r))}
`).join(''); return list; } const columns = [...new Set(rows.flatMap((r) => Object.keys(r)))] .filter((c) => !/token|password|fingerprint/i.test(c)) .slice(0, 12); const wrap = document.createElement('div'); wrap.className = 'table-responsive'; wrap.innerHTML = ` ${columns.map((c) => ``).join('')} ${rows.slice(0, 100).map((row) => `${columns.map((c) => ``).join('')}`).join('')}
${esc(humanize(c))}
${cellHtml(c, row[c])}
`; return wrap; } const LONG_CELL = 70; function cellHtml(column, value) { if (value === null || value === undefined || value === '') return '—'; if (typeof value === 'object') return `${esc(JSON.stringify(value).slice(0, 60))}`; if (/status|type|state|result/i.test(column)) { return `${esc(labelStatus(value))}`; } const text = formatValue(value, column); // Stack traces and URLs would otherwise stretch the row far past the // viewport and push every other column out of view. if (String(text).length > LONG_CELL) { return `${esc(String(text).slice(0, LONG_CELL))}… `; } return esc(text); } function formatValue(value, column = '') { if (typeof value === 'boolean') return value ? t('Yes') : t('No'); if (value === null || value === undefined || value === '') return '—'; const str = String(value); if (/^\d{4}-\d{2}-\d{2}[ T]/.test(str)) return fmtDate(str); if (/(price|fare|amount|revenue|earning|balance|payout|commission|total_paid)/i.test(column) && !isNaN(Number(str))) { return fmtMoney(str); } // Identifiers, phones and codes are digit strings that must never be // grouped with thousand separators — "0790000000" is not 790,000,000. const isIdentifier = /(^|_)(id|phone|code|number|otp|year|zip|lat|lng|latitude|longitude)($|_)/i.test(column); if (!isIdentifier && !str.startsWith('0') && /^-?\d+(\.\d+)?$/.test(str) && str.length < 12) { return fmtNum(str); } return str; } function humanize(key) { return String(key) .replace(/[_-]+/g, ' ') .replace(/([a-z])([A-Z])/g, '$1 $2') .replace(/\b\w/g, (m) => m.toUpperCase()) .trim(); } function msgNode(text) { const node = document.createElement('div'); node.className = 'table-msg'; node.textContent = text; return node; } // ── Diagnostics ────────────────────────────────────────────────────────── // Prints exactly what the server replies for every endpoint the console // uses, so a blank dashboard can be traced to a status code instead of a // guess: 404 = wrong API base, 403 = role/device rejection, 401 = token. const PROBES = [ ['Dashboard stats', '/Admin/dashbord.php'], ['Rides', '/Admin/rides/get_rides_by_status.php', { status: 'All' }], ['Captains', '/Admin/AdminCaptain/get.php', { page: 1 }], ['Passengers', '/Admin/getPassengerDetails.php'], ['Pending approvals', '/Admin/Staff/pending.php'], ['Rides per month', '/Admin/AdminRide/getRidesPerMonth.php'], ]; async function runDiagnostics() { const out = el.diagnosticsOutput; if (!out) return; busy(el.runDiagnosticsBtn, true, 'Running…'); const lines = [ `Siro Admin diagnostics — ${new Date().toISOString()}`, `Console build: ${BUILD}`, `Page origin : ${location.origin}`, `API base : ${API_BASE}`, `Fingerprint : ${deviceFingerprint.slice(0, 20)}…`, `Token : ${session?.jwt ? 'present (role ' + session.role + ')' : 'MISSING — not signed in'}`, '─'.repeat(64), ]; for (const [name, path, params] of PROBES) { const url = API_BASE + path; const started = performance.now(); let form = null; if (params) { form = new FormData(); Object.entries(params).forEach(([k, v]) => form.append(k, v)); } try { const res = await fetch(url, { method: form ? 'POST' : 'GET', body: form, headers: { 'X-Device-FP': deviceFingerprint, ...(session?.jwt ? { Authorization: `Bearer ${session.jwt}` } : {}), }, }); const text = await res.text(); const ms = Math.round(performance.now() - started); lines.push( `${res.ok ? '✔' : '✘'} ${name}`, ` ${url}`, ` HTTP ${res.status} ${res.statusText} · ${ms}ms · ${text.length} bytes`, ` ${collapse(text).slice(0, 400)}`, '' ); } catch (err) { lines.push( `✘ ${name}`, ` ${url}`, ` NETWORK FAILURE — ${err.message}`, ' (blocked by CORS, DNS, mixed content, or the host is unreachable)', '' ); } } out.textContent = lines.join('\n'); busy(el.runDiagnosticsBtn, false, 'Run diagnostics'); } // Outbound message — confirmed explicitly because it reaches a real person // and cannot be recalled. async function sendWhatsApp() { if (!isSuperAdmin()) { toast('Sending messages is restricted to super admins.', 'warning'); return; } const receiver = $('waReceiver').value.trim(); const message = $('waMessage').value.trim(); const status = $('waStatus'); if (!receiver || !message) { toast('Enter both a recipient and a message.', 'warning'); return; } if (!confirm(`Send this WhatsApp message to ${receiver}?\n\n${message}`)) return; status.textContent = 'Sending…'; try { await api('/Admin/send_whatsapp_message.php', { params: { receiver, message } }); status.textContent = `Sent to ${receiver}`; $('waMessage').value = ''; toast('Message sent.', 'success'); } catch (err) { status.textContent = ''; toast(`Send failed: ${err.message}`, 'danger'); } } // Super-admin only: mirrors the Flutter EncryptToolPage (Admin/ggg.php). async function runCryptoTool(action) { if (!isSuperAdmin()) { toast('This tool is restricted to super admins.', 'warning'); return; } const text = el.decryptInput.value.trim(); const adminPhone = $('decryptPhone').value.trim(); if (!text || !adminPhone) { toast('Enter both your admin phone and the value.', 'warning'); return; } el.decryptOutput.textContent = 'Working…'; try { const payload = await api('/Admin/ggg.php', { params: { action, text, admin_phone: adminPhone }, }); el.decryptOutput.textContent = payload?.result ?? JSON.stringify(payload, null, 2); } catch (err) { el.decryptOutput.textContent = `Failed: ${err.message}`; } } function collapse(text) { return String(text).replace(/\s+/g, ' ').trim() || '(empty response body)'; } function setupDiagnostics() { if (!el.apiBaseSelect) return; el.apiBaseSelect.innerHTML = API_CANDIDATES .map((c) => ``) .join('') + ''; const known = API_CANDIDATES.some((c) => c.value === API_BASE); el.apiBaseSelect.value = known ? API_BASE : '__custom__'; el.apiBaseCustom.value = known ? '' : API_BASE; el.apiBaseCustom.hidden = known; el.apiBaseSelect.addEventListener('change', () => { el.apiBaseCustom.hidden = el.apiBaseSelect.value !== '__custom__'; }); el.saveApiBaseBtn.addEventListener('click', () => { const chosen = el.apiBaseSelect.value === '__custom__' ? el.apiBaseCustom.value.trim().replace(/\/$/, '') : el.apiBaseSelect.value; if (!chosen) return; API_BASE = chosen; localStorage.setItem(API_BASE_KEY, chosen); toast(`API base set to ${chosen}. Reloading data…`, 'success'); renderSessionInfo(); if (session) loadEverything(); }); el.runDiagnosticsBtn.addEventListener('click', runDiagnostics); document.querySelectorAll('[data-action="decrypt"], [data-action="encrypt"]').forEach((btn) => btn.addEventListener('click', () => runCryptoTool(btn.dataset.action))); $('waSendBtn')?.addEventListener('click', sendWhatsApp); el.copyDiagnosticsBtn.addEventListener('click', async () => { try { await navigator.clipboard.writeText(el.diagnosticsOutput.textContent); toast('Diagnostics report copied.', 'success'); } catch { toast('Copy failed — select the text manually.', 'warning'); } }); } function showRideDetails(r) { const body = $('modalBodyContent'); const rows = [ [t('Status'), labelStatus(r.status)], [t('Passenger'), r.passenger_full_name], [t('Passenger phone'), maskPhone(r.p_phone)], [t('Completed trips (passenger)'), fmtInt(r.p_completed)], [t('Captain'), r.driver_full_name], [t('Captain phone'), maskPhone(r.d_phone)], [t('Captain completed / cancelled'), `${fmtInt(r.d_completed)} / ${fmtInt(r.d_canceled)}`], [t('Pickup'), r.address_start || r.start_location], [t('Drop-off'), r.address_end || r.end_location], [t('Distance'), r.distance ? `${fmtNum(r.distance)} km` : '—'], [t('Passenger fare'), fmtMoney(rideFare(r))], [t('Captain earning'), r.price_for_driver ? fmtMoney(r.price_for_driver) : '—'], [t('Payment method'), r.paymentMethod], [t('Service'), r.carType], [t('Requested'), rideTimestamp(r)], [t('Started'), r.rideTimeStart ? fmtDate(r.rideTimeStart) : '—'], [t('Finished'), r.rideTimeFinish ? fmtDate(r.rideTimeFinish) : '—'], [t('Cancellation note'), r.cancel_reason], ]; body.innerHTML = `
${rows.map(([k, v]) => `
${k}${esc(String(v ?? '—') || '—')}
`).join('')}
`; $('detailsModal').classList.add('active'); } window.closeModal = () => $('detailsModal')?.classList.remove('active'); // ── Charts (dependency-free canvas rendering) ──────────────────────────── const chartData = new Map(); function prepareCanvas(id) { const canvas = $(id); if (!canvas || !canvas.parentElement) return null; const ratio = window.devicePixelRatio || 1; // Collapse the canvas first: a sized canvas props its own container open, // so measuring before resetting would make charts grow but never shrink. canvas.width = 0; canvas.height = 0; const w = canvas.parentElement.clientWidth; const h = canvas.parentElement.clientHeight; canvas.width = w * ratio; canvas.height = h * ratio; const ctx = canvas.getContext('2d'); ctx.scale(ratio, ratio); ctx.clearRect(0, 0, w, h); return { ctx, w, h }; } function drawLineChart(id, series) { chartData.set(id, { type: 'line', series }); const c = prepareCanvas(id); if (!c || !series.length) return; const { ctx, w, h } = c; const padX = 34, padTop = 16, padBottom = 26; const max = Math.max(...series.map((s) => s.value), 1); const stepX = series.length > 1 ? (w - padX * 2) / (series.length - 1) : 0; const y = (v) => h - padBottom - (v / max) * (h - padTop - padBottom); const pts = series.map((s, i) => ({ x: padX + i * stepX, y: y(s.value) })); ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.fillStyle = '#64748b'; ctx.font = '10px Inter, sans-serif'; for (let i = 0; i <= 4; i++) { const gy = padTop + i * (h - padTop - padBottom) / 4; ctx.beginPath(); ctx.moveTo(padX, gy); ctx.lineTo(w - padX + 10, gy); ctx.stroke(); ctx.textAlign = 'right'; ctx.fillText(String(Math.round(max - i * max / 4)), padX - 6, gy + 3); } const grad = ctx.createLinearGradient(0, padTop, 0, h - padBottom); grad.addColorStop(0, 'rgba(99,102,241,0.35)'); grad.addColorStop(1, 'rgba(99,102,241,0)'); ctx.beginPath(); pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y))); ctx.lineTo(pts[pts.length - 1].x, h - padBottom); ctx.lineTo(pts[0].x, h - padBottom); ctx.closePath(); ctx.fillStyle = grad; ctx.fill(); ctx.beginPath(); pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y))); ctx.strokeStyle = '#6366f1'; ctx.lineWidth = 2.5; ctx.lineJoin = 'round'; ctx.stroke(); const last = pts[pts.length - 1]; ctx.beginPath(); ctx.arc(last.x, last.y, 4.5, 0, Math.PI * 2); ctx.fillStyle = '#6366f1'; ctx.fill(); ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.stroke(); ctx.fillStyle = '#64748b'; ctx.textAlign = 'center'; const every = Math.ceil(series.length / 7); series.forEach((s, i) => { if (i % every === 0 || i === series.length - 1) ctx.fillText(s.label, pts[i].x, h - 8); }); } // Multiple series on one axis, sharing a scale so the lines are comparable. function drawMultiLine(id, seriesList) { chartData.set(id, { type: 'multi', series: seriesList }); const c = prepareCanvas(id); if (!c || !seriesList.length || !seriesList[0].points.length) return; const { ctx, w, h } = c; const padL = 38, padR = 14, padTop = 14, padBottom = 26; const count = seriesList[0].points.length; const max = Math.max(1, ...seriesList.flatMap((s) => s.points.map((p) => p.value))); const stepX = count > 1 ? (w - padL - padR) / (count - 1) : 0; const y = (v) => h - padBottom - (v / max) * (h - padTop - padBottom); // grid + scale ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.fillStyle = '#64748b'; ctx.font = '10px Inter, sans-serif'; const ticks = Math.min(4, max); for (let i = 0; i <= ticks; i++) { const gy = padTop + i * (h - padTop - padBottom) / ticks; ctx.beginPath(); ctx.moveTo(padL, gy); ctx.lineTo(w - padR, gy); ctx.stroke(); ctx.textAlign = 'right'; ctx.fillText(String(Math.round(max - i * max / ticks)), padL - 6, gy + 3); } seriesList.forEach((serie) => { const pts = serie.points.map((p, i) => ({ x: padL + i * stepX, y: y(p.value) })); const grad = ctx.createLinearGradient(0, padTop, 0, h - padBottom); grad.addColorStop(0, serie.color + '55'); grad.addColorStop(1, serie.color + '00'); ctx.beginPath(); pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y))); ctx.lineTo(pts[pts.length - 1].x, h - padBottom); ctx.lineTo(pts[0].x, h - padBottom); ctx.closePath(); ctx.fillStyle = grad; ctx.fill(); ctx.beginPath(); pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y))); ctx.strokeStyle = serie.color; ctx.lineWidth = 2.2; ctx.lineJoin = 'round'; ctx.stroke(); // mark only non-zero days: dots on a flat zero line are just noise serie.points.forEach((p, i) => { if (!p.value) return; ctx.beginPath(); ctx.arc(pts[i].x, pts[i].y, 3.2, 0, Math.PI * 2); ctx.fillStyle = serie.color; ctx.fill(); }); }); ctx.fillStyle = '#64748b'; ctx.textAlign = 'center'; const every = Math.ceil(count / 8); seriesList[0].points.forEach((p, i) => { if (i % every === 0 || i === count - 1) ctx.fillText(p.label, padL + i * stepX, h - 8); }); } function drawBarChart(id, series) { chartData.set(id, { type: 'bar', series }); const c = prepareCanvas(id); if (!c || !series.length) return; const { ctx, w, h } = c; const padBottom = 28, padTop = 14; const max = Math.max(...series.map((s) => s.value), 1); const slot = w / series.length; const barW = Math.min(64, slot * 0.5); series.forEach((s, i) => { const barH = (s.value / max) * (h - padTop - padBottom); const x = i * slot + (slot - barW) / 2; const yTop = h - padBottom - barH; const grad = ctx.createLinearGradient(0, yTop, 0, h - padBottom); grad.addColorStop(0, '#6366f1'); grad.addColorStop(1, 'rgba(99,102,241,0.25)'); ctx.fillStyle = grad; roundRect(ctx, x, yTop, barW, barH, 6); ctx.fill(); ctx.fillStyle = '#f8fafc'; ctx.font = '600 11px Inter, sans-serif'; ctx.textAlign = 'center'; ctx.fillText(fmtInt(s.value), x + barW / 2, yTop - 5); ctx.fillStyle = '#64748b'; ctx.font = '10px Inter, sans-serif'; ctx.fillText(s.label, x + barW / 2, h - 9); }); } function drawDonut(id, slices, legendEl) { chartData.set(id, { type: 'donut', series: slices, legendEl }); const c = prepareCanvas(id); if (!c) return; const { ctx, w, h } = c; const total = slices.reduce((s, x) => s + x.value, 0); const cx = w / 2, cy = h / 2; const r = Math.min(w, h) / 2 - 8; const inner = r * 0.62; if (!total) { ctx.fillStyle = '#64748b'; ctx.font = '12px Inter, sans-serif'; ctx.textAlign = 'center'; ctx.fillText('No ride data', cx, cy); } else { let angle = -Math.PI / 2; slices.forEach((s) => { const sweep = (s.value / total) * Math.PI * 2; ctx.beginPath(); ctx.arc(cx, cy, r, angle, angle + sweep); ctx.arc(cx, cy, inner, angle + sweep, angle, true); ctx.closePath(); ctx.fillStyle = s.color; ctx.fill(); angle += sweep; }); ctx.fillStyle = '#f8fafc'; ctx.font = '600 18px Outfit, Inter, sans-serif'; ctx.textAlign = 'center'; ctx.fillText(fmtInt(total), cx, cy + 2); ctx.fillStyle = '#64748b'; ctx.font = '10px Inter, sans-serif'; ctx.fillText('TOTAL RIDES', cx, cy + 18); } if (legendEl) { legendEl.innerHTML = slices.map((s) => ` ${s.label} ${total ? ((s.value / total) * 100).toFixed(1) : '0.0'}% `).join(''); } } function roundRect(ctx, x, y, w, h, r) { const radius = Math.min(r, h / 2, w / 2); ctx.beginPath(); ctx.moveTo(x + radius, y); ctx.arcTo(x + w, y, x + w, y + h, radius); ctx.arcTo(x + w, y + h, x, y + h, radius); ctx.arcTo(x, y + h, x, y, radius); ctx.arcTo(x, y, x + w, y, radius); ctx.closePath(); } function redrawCharts() { chartData.forEach((cfg, id) => { if (cfg.type === 'multi') drawMultiLine(id, cfg.series); else if (cfg.type === 'line') drawLineChart(id, cfg.series); else if (cfg.type === 'bar') drawBarChart(id, cfg.series); else drawDonut(id, cfg.series, cfg.legendEl); }); } // ── Navigation & misc events ───────────────────────────────────────────── function setupNavigation() { const navItems = document.querySelectorAll('.nav-item[data-view]'); navItems.forEach((item) => { item.addEventListener('click', (e) => { e.preventDefault(); navItems.forEach((n) => n.classList.remove('active')); item.classList.add('active'); document.querySelectorAll('.page-view').forEach((v) => v.classList.toggle('active', v.id === item.dataset.view)); if (window.innerWidth <= 992) el.sidebar.classList.remove('open'); redrawCharts(); // Opening a section always refetches it: an operator switching to a // list expects what the database holds now, not what it held when the // console was first opened. if (session) refreshView(item.dataset.view, item.dataset.module); }); }); el.toggleSidebar?.addEventListener('click', () => el.sidebar.classList.toggle('open')); } // Maps a sidebar entry to the loader that owns its data. const VIEW_LOADERS = { dashboardView: () => { loadStats().catch(() => {}); loadRidesTrend().catch(() => {}); }, ridesView: () => loadRides().catch(() => {}), driversView: () => loadDrivers().catch(() => {}), passengersView: () => loadPassengers().catch(() => {}), financialsView: () => loadStats().catch(() => {}), complaintsView: () => loadStats().catch(() => {}), approvalsView: () => loadApprovals().catch(() => {}), systemView: () => renderSessionInfo(), }; function refreshView(viewId, moduleId) { setConnection('loading', t('Loading live data…')); setTimeout(() => { if (el.connectionPill?.dataset.state === 'loading') setConnection('live', t('Live database')); }, 900); const mod = MODULES.find((m) => m.id === moduleId); if (mod) { loadModule(mod, true); // force: bypass the loaded-once cache return; } VIEW_LOADERS[viewId]?.(); } function setupDataEvents() { el.refreshBtn?.addEventListener('click', () => { if (!session) return; const active = document.querySelector('.nav-item.active'); if (active) refreshView(active.dataset.view, active.dataset.module); else loadEverything(); }); $('langToggle')?.addEventListener('click', () => setLanguage(lang === 'ar' ? 'en' : 'ar')); el.rideStatusFilter?.addEventListener('change', () => loadRides().catch(() => {})); el.driversPrev?.addEventListener('click', () => { if (driversPage > 1) { driversPage--; loadDrivers().catch(() => {}); } }); el.driversNext?.addEventListener('click', () => { if (driversPage < driversPages) { driversPage++; loadDrivers().catch(() => {}); } }); el.ridesMore?.addEventListener('click', () => { ridesShown += RIDES_PAGE_SIZE; renderRides(); }); // Server-side lookups: these hit dedicated endpoints rather than filtering // the page, so an operator can find a record that is not in the last batch. bindSearch(el.ridesSearch, el.ridesSearchBtn, (term) => lookupRidesByPhone(term), () => loadRides()); bindSearch(el.driversSearch, el.driversSearchBtn, (term) => lookupCaptain(term), () => loadDrivers()); bindSearch(el.passengersSearch, el.passengersSearchBtn, (term) => lookupPassenger(term), () => loadPassengers()); el.globalSearch?.addEventListener('input', (e) => { const q = e.target.value.toLowerCase(); const active = document.querySelector('.page-view.active'); active?.querySelectorAll('tbody tr').forEach((tr) => { tr.style.display = tr.textContent.toLowerCase().includes(q) ? '' : 'none'; }); }); document.addEventListener('click', (e) => { const cell = e.target.closest('.cell-long'); if (!cell) return; const full = cell.nextElementSibling; if (!full?.classList.contains('cell-full')) return; cell.hidden = true; full.hidden = false; }); let resizeTimer; window.addEventListener('resize', () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(redrawCharts, 150); }); } function bindSearch(input, button, onSearch, onClear) { if (!input) return; const run = () => { const term = input.value.trim(); if (term) onSearch(term); else onClear(); }; button?.addEventListener('click', run); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') run(); }); input.addEventListener('input', () => { if (!input.value.trim()) onClear(); }); } // ── Small helpers ──────────────────────────────────────────────────────── function setKpi(key, value) { document.querySelectorAll(`[data-kpi="${key}"]`).forEach((n) => { n.textContent = value; }); } function markKpisUnavailable() { document.querySelectorAll('[data-kpi]').forEach((n) => { n.textContent = 'n/a'; }); } function tableMessage(tbody, cols, message, isError = false) { if (!tbody) return; tbody.innerHTML = `${esc(message)}`; } function setConnection(state, text) { if (!el.connectionPill) return; el.connectionPill.dataset.state = state; el.connectionText.textContent = text; // Visible feedback that a fetch is running: a section with no data still // needs to show that it tried. el.refreshBtn?.classList.toggle('is-busy', state === 'loading'); } function busy(btn, isBusy, label) { if (!btn) return; btn.disabled = isBusy; const span = btn.querySelector('span'); if (span) span.textContent = label; } const num = (v) => Number(v) || 0; const fmtInt = (v) => num(v).toLocaleString('en-US'); const fmtNum = (v) => num(v).toLocaleString('en-US', { maximumFractionDigits: 2 }); const fmtMoney = (v) => num(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' JOD'; function fmtDate(value, dateOnly = false) { if (!value) return '—'; const d = new Date(String(value).replace(' ', 'T')); if (isNaN(d)) return String(value); return dateOnly ? d.toLocaleDateString() : d.toLocaleString(); } function rating(v) { const r = Number(v); if (!r) return 'unrated'; return ` ${r.toFixed(2)}`; } function shorten(text, max = 26) { if (!text) return '—'; const s = String(text); return s.length > max ? s.slice(0, max - 1) + '…' : s; } // Two status generations coexist in the ride table: the legacy CamelCase set // and the lowercase set written by the current ride pipeline. const STATUS_LABELS = { finished: 'Completed', completed: 'Completed', begin: 'In progress', started: 'In progress', apply: 'Captain assigned', applied: 'Captain assigned', accepted: 'Captain assigned', claimed: 'Captain assigned', arrived: 'Captain arrived', new: 'Waiting', nothing: 'Waiting', waiting: 'Waiting', wait: 'Waiting', pending: 'Waiting', searching: 'Searching for a captain', cancel: 'Cancelled', cancelfromdriver: 'Cancelled by captain', cancelfromdriverafterapply: 'Cancelled by captain', cancelfrompassenger: 'Cancelled by passenger', cancelled_by_driver: 'Cancelled by captain', cancelled_by_passenger: 'Cancelled by passenger', cancelled_no_driver_found: 'No captain found', timeout: 'Timed out', refused: 'Refused', pending_review: 'Pending review', }; function labelStatus(status) { if (!status) return 'Unknown'; return t(STATUS_LABELS[String(status).toLowerCase()] || humanize(status)); } function badgeClass(status) { const s = String(status || '').toLowerCase(); if (['finished', 'completed', 'active', 'approved', 'online', 'success'].includes(s)) return 'badge-success'; if (s.startsWith('cancel') || ['timeout', 'refused', 'suspended', 'rejected', 'blocked', 'failure', 'error'].includes(s)) return 'badge-danger'; if (['begin', 'apply', 'applied', 'accepted', 'claimed', 'started', 'arrived'].includes(s)) return 'badge-primary'; if (['pending', 'pending_review', 'new', 'wait', 'waiting', 'nothing', 'searching'].includes(s)) return 'badge-warning'; return 'badge-info'; } function formatRole(role) { return String(role || '').replace(/_/g, ' ').replace(/\b\w/g, (m) => m.toUpperCase()) || 'Admin'; } function initials(name) { return String(name || 'A').trim().split(/\s+/).slice(0, 2).map((w) => w[0]).join('').toUpperCase(); } function esc(value) { return String(value ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } function toast(message, type = 'info') { const icons = { success: 'ph-check-circle', danger: 'ph-warning-octagon', warning: 'ph-warning', info: 'ph-info' }; const node = document.createElement('div'); node.className = `toast toast-${type}`; node.innerHTML = `${esc(message)}`; document.body.appendChild(node); setTimeout(() => { node.classList.add('leaving'); setTimeout(() => node.remove(), 300); }, 4200); } })();