/* ==========================================================================
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 = `
${esc(title)}
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 = `
${esc(title)}
${entries.map(([k, v]) => `
${esc(k)} ${esc(String(v))}
`).join('')}
`;
} catch (err) {
if (handleApiError(err, 'profile')) return;
body.innerHTML = `
${esc(title)}
${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('Approve')}
` : `${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) => `
`).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) => `
`).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 = ``;
let payload;
try {
payload = await api('/Admin/v2/analytics/growth.php');
} catch (err) {
if (handleApiError(err, 'growth')) return;
host.innerHTML = ``;
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')}
${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 `
`;
}
// ── Advanced analytics ───────────────────────────────────────────────────
async function renderAnalytics(host) {
host.innerHTML = `
${t('Revenue is counted from completed rides only.')}
${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 = `
${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 = `
`;
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('Name')} ${t('Type')} ${t('City')} ${t('Contract')}
${t('Captains')} ${t('Vehicles')} ${t('Routes')} ${t('Enrolled')}
${orgs.map((o, i) => `
${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)}
`).join('')}
`;
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 = `
${esc(org.name_ar || org.name_en || `Organisation #${orgId}`)}
${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})
`;
renderPayload($('orgTrips'), trips);
loadOrgAdmins(orgId);
$('orgAdminAdd').addEventListener('click', () => addOrgAdmin(orgId));
} catch (err) {
if (handleApiError(err, 'org-details')) return;
body.innerHTML = `
${lang === 'ar' ? 'مؤسسة' : 'Organisation'}
${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 || ''))}
${Number(a.is_active) ? ` ${t('Disable')}` : ` ${t('Enable')}`}
`).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 ? esc(org.name_ar || (lang === 'ar' ? 'تعديل المؤسسة' : 'Edit organisation')) : (lang === 'ar' ? 'مؤسسة جديدة' : 'New organisation')}
${editing ? '' : `
${t('Type')} ${lang === 'ar' ? 'إلزامي' : 'required'}
${ORG_TYPES.map((t) => `${humanize(t)} `).join('')}
${lang === 'ar' ? 'الاسم (عربي)' : 'Name (Arabic)'} ${lang === 'ar' ? 'إلزامي' : 'required'}
${lang === 'ar' ? 'الاسم (إنجليزي)' : 'Name (English)'}
${t('Country')} ${lang === 'ar' ? 'حرفين' : '2 letters'}
${lang === 'ar' ? 'اسم المدير المؤسس' : 'Founding admin name'} ${lang === 'ar' ? 'إلزامي' : 'required'}
${lang === 'ar' ? 'هاتف المدير المؤسس' : 'Founding admin phone'} ${lang === 'ar' ? 'إلزامي' : 'required'}
`}
${t('City')}
${editing ? `
${lang === 'ar' ? 'البريد الإلكتروني' : 'Contact email'}
${lang === 'ar' ? 'الموقع الإلكتروني' : 'Website'}
${t('Contract')}
${CONTRACT_STATES.map((c) => `${humanize(c)} `).join('')}
${lang === 'ar' ? 'انتهاء الفترة التجريبية' : 'Trial ends'}
` : ''}
${editing ? t('Save changes') : t('Create organisation')}
`;
$('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' ? 'يزيل رقم الهاتف من القائمة السوداء حتى يتمكن من التسجيل مرة أخرى. لا يُعيد الحساب المحذوف.' : 'Removes the phone number from the blacklist so it can register again. It does not restore a deleted account.'}
${t('Account type')}
${t('Passenger')}
${lang === 'ar' ? 'كابتن' : 'Captain'}
${t('Phone')}
${t('Lift block')}
`;
$('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 = `
`;
renderPayload(panel.querySelector('.panel-body'), payload);
} catch (err) {
if (handleApiError(err, 'blacklist')) return;
panel.innerHTML = `${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('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 = `
`;
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 = `
`;
renderPayload(panel.querySelector('.panel-body'), payload);
} catch (err) {
if (handleApiError(err, 'campaign-log')) return;
panel.innerHTML = `${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 = ``;
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) => `
`).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 = ``;
return;
}
if (!drivers.length && offset === 0) {
host.innerHTML = `${t('No captains are awaiting document review.')}
`;
return;
}
host.innerHTML = `
ID Name Phone
${drivers.map((d) => `
#${esc(d.id)}
${esc(`${d.first_name || ''} ${d.last_name || ''}`.trim() || 'Unnamed')}
${esc(maskPhone(d.phone))}
${t('Review documents')}
`).join('')}
${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 = `
${lang === 'ar' ? 'الوثائق' : 'Documents'} (${documents.length})
${documents.length ? `
${documents.map((doc) => `
${doc.link
? `
`
: ` ${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…')}
`;
$('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 = `
${notes}
${rows.length ? `
${t('ID')} ${t('Name')} ${t('Phone')} ${t('Type')} ${lang === 'ar' ? 'طُلب' : 'Requested'}
${rows.map((r) => `
#${esc(r.id)}
${esc(r.name || '—')}
${esc(maskPhone(r.phone))}
${esc(r.type)}
${esc(fmtDate(r.created_at, true))}
${t('Activate')}
`).join('')}
` : (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 = `
`;
renderPayload(panel.querySelector('.panel-body'), payload);
} catch (err) {
if (handleApiError(err, 'employees')) return;
panel.innerHTML = `${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 = ``;
return;
}
if (!routes.length) {
host.innerHTML = `${t('No routes are waiting for approval.')}
`;
return;
}
host.innerHTML = routes.map((route, index) => `
${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) => `
${esc(s.name_ar || (lang === 'ar' ? 'محطة بدون اسم' : 'Unnamed stop'))}
${Number(s.is_major) ? `${t('major')} ` : ''}
${esc(shortCoord(`${s.latitude},${s.longitude}`))}
`).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('Audience')}
${t('All captains')}
${t('All passengers')}
${t('Notification title')} ${lang === 'ar' ? 'حد أقصى 120' : 'max 120'}
${lang === 'ar' ? 'الرسالة' : 'Message'} ${lang === 'ar' ? 'حد أقصى 1000' : 'max 1000'}
${t('Review & send')}
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 = ``;
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 = ``;
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 `
`;
}
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) => `${esc(humanize(c))} `).join('')}
${rows.slice(0, 100).map((row) => `${columns.map((c) => `${cellHtml(c, row[c])} `).join('')} `).join('')}
`;
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))}…
${esc(String(text))} `;
}
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) => `${esc(c.label)} `)
.join('') + 'Custom… ';
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 = `
${lang === 'ar' ? 'رحلة' : 'Trip'} #${esc(r.id)}
${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);
}
})();