Files
intaleq/dashboard/siro-admin/js/app.js
T
Hamza-AyedandClaude Opus 5 92dc6b3641 chore: استيراد أولي من سيرو (ecfe7568) — بلا أي تعديل
نسخة كاملة من مستودع سيرو عند ecfe7568 لتكون أساس تطبيق «انطلق».
نُسخ المتعقَّب في git فقط (12,509 ملفاً / 302 م.ب) بـ git archive، لا
`cp -r` — فاستُثنيت تلقائياً مخلفات البناء (build · node_modules ·
.dart_tool · .gradle · Pods ≈ 10.7 غ.ب) وكل ما يستثنيه .gitignore.

هذا الكوميت **بلا أي تعديل عمداً** حتى يكون كل ما يليه فرقاً مقروءاً
مقابل سيرو الأصلي. سيرو نفسه لم يُمسّ.

⚠️ لا يبني بعد: `.env` و`lib/env/env.g.dart` غير متعقَّبين في سيرو (وهذا
صحيح — أسرار لكل مستأجر). كل تطبيق فلاتر هنا يحتاج .env خاصاً بانطلق ثم
توليد env.g.dart عبر build_runner. لا تُنسخ أسرار سيرو.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 05:10:29 +03:00

4178 lines
190 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ==========================================================================
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': 'نقطة الاستلام',
'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': 'بحث بمعرّف الراكب أو البريد الإلكتروني أو الهاتف',
// API error messages
' — the endpoint returned HTML, check the API base URL': ' — الـ API أرجع HTML، تحقق من عنوان الخادم',
'Session rejected by the server': 'الخادم رفض الجلسة',
'Request failed': 'فشل الطلب',
// Auth
'your WhatsApp': 'واتسابك',
'Verification code sent.': 'تم إرسال رمز التحقق.',
'No token issued.': 'لم يُصدر توكن.',
'Admin': 'المشرف',
'Welcome back,': 'مرحباً بعودتك،',
// Load everything
'No data — check diagnostics': 'لا توجد بيانات — تحقق من التشخيص',
'Every endpoint failed.': 'فشل كل الـ APIs.',
'Open "Session & Security" → Run diagnostics.': 'افتح "الجلسة والأمان" ← شغّل التشخيص.',
// Rides table
'Unknown': 'غير معروف',
'Unassigned': 'لم يُعيَّن',
'No rides found for': 'لم تُعثر على رحلات لـ',
'Search results for': 'نتائج البحث عن',
'No captain matches': 'لا يوجد كابتن يطابق',
'No passenger matches': 'لا يوجد راكب يطابق',
'Unnamed': 'بدون اسم',
// Captain/Passenger profile
'Captain #': 'كابتن #',
'Passenger #': 'راكب #',
'Loading profile…': 'جارٍ تحميل الملف الشخصي…',
'No profile returned for this record.': 'لم تُرجَع ملفات لهذا السجل.',
// Approvals
'Set admin': 'تعيين المشرف',
'to': 'إلى',
'approved': 'تم اعتماده',
'rejected': 'تم رفضه',
// Blacklist
'Enter the phone number of the account to remove.': 'أدخل رقم الهاتف المراد حذفه.',
'The retyped phone does not match — nothing was deleted.': 'رقم الهاتف المُعاد إدخاله غير متطابق — لم يُحذف شيء.',
'Removing a captain requires their account ID.': 'حذف الكابتن يتطلب معرّف حسابه.',
'Permanently delete': 'حذف نهائياً',
'and blacklist that number?': 'وإضافة الرقم للقائمة السوداء؟',
'The account is deleted from the database. This cannot be undone.': 'الحساب يُحذف من قاعدة البيانات. لا يمكن التراجع.',
'Deleting…': 'جارٍ الحذف…',
'Captain removed and blacklisted.': 'تم حذف الكابتن وإضافته للقائمة السوداء.',
'Passenger removed and blacklisted.': 'تم حذف الراكب وإضافته للقائمة السوداء.',
'Remove and blacklist': 'حذف ووضع في القائمة السوداء',
'Delete and blacklist': 'حذف ووضع في القائمة السوداء',
// Lift block
'Enter the blocked phone number.': 'أدخل رقم الهاتف المحظور.',
'They will be able to register again.': 'سيتمكن من التسجيل مرة أخرى.',
'Working…': 'جارٍ التنفيذ…',
'Block lifted.': 'تم رفع الحظر.',
'Lift block': 'رفع الحظر',
// Campaign
'Parameters changed since the preview': 'تغيّرت المعايير منذ المعاينة',
'preview again before launching': 'عاين مرة أخرى قبل الإطلاق',
'Launch this campaign in': 'إطلاق الحملة في',
'It creates a discount code valid for 7 days and pushes a notification to every passenger there.': 'ينشئ كود خصم صالحاً لمدة ٧ أيام ويرسل إشعاراً لكل راكب هناك.',
'This cannot be undone.': 'لا يمكن التراجع.',
'The AI found no opportunity — launching would still send.': 'لم يجد الذكاء الاصطناعي فرصة — الإطلاق سيرسل على أي حال.',
'Launch is now enabled.': 'الإطلاق متاح الآن.',
'Campaign dispatched.': 'تم إرسال الحملة.',
// App versions
'Version unchanged.': 'الإصدار لم يتغيّر.',
'Use digits separated by dots, e.g. 1.4.2': 'استخدم أرقاماً مفصولة بنقاط، مثال: 1.4.2',
'Users on an older build may be prompted or forced to update.': 'قد يُطلب من المستخدمين التحديث أو يُجبرون عليه.',
// Driver docs
'Loading documents…': 'جارٍ تحميل الوثائق…',
'Activate captain': 'تفعيل الكابتن',
'They will be able to accept rides immediately.': 'سيتمكّن من قبول الرحلات فوراً.',
'WARNING: no documents are on file for this captain.': 'تحذير: لا توجد وثائق مسجّلة لهذا الكابتن.',
'document(s) reviewed': 'وثيقة/وثائق تمت مراجعتها',
'Captain activated.': 'تم تفعيل الكابتن.',
// Staff
'Activate account': 'تفعيل الحساب',
'They will be able to sign in immediately.': 'سيتمكن من تسجيل الدخول فوراً.',
'Name and password are required.': 'الاسم وكلمة المرور مطلوبان.',
'Use a password of at least 8 characters.': 'استخدم كلمة مرور من ٨ أحرف على الأقل.',
'Only a super admin can create administrator accounts.': 'المدير العام فقط يمكنه إنشاء حسابات مشرفين.',
'ADMINISTRATOR': 'مدير',
'customer service': 'خدمة عملاء',
'Create a account for': 'إنشاء حساب لـ',
'Administrators can see and change platform data.': 'المشرفون يمكنهم رؤية وتعديل بيانات المنصة.',
'Customer service staff can view operational data.': 'موظفو خدمة العملاء يمكنهم رؤية البيانات التشغيلية.',
'Creating…': 'جارٍ الإنشاء…',
'Staff account created.': 'تم إنشاء حساب الموظف.',
'Could not create account:': 'تعذّر إنشاء الحساب:',
// Routes
'approve': 'اعتماد',
'reject': 'رفض',
'The route goes live and passengers can ride it.': 'المسار يصبح نشطاً ويمكن للركاب استخدامه.',
'The organisation will have to resubmit the route.': 'سيتعيّن على المؤسسة إعادة تقديم المسار.',
'Approve route': 'اعتماد المسار',
'Reject route': 'رفض المسار',
'from': 'من',
'Route': 'المسار',
'Route approved.': 'تم اعتماد المسار.',
'Route rejected.': 'تم رفض المسار.',
// Broadcast
'Broadcasting is restricted to super admins.': 'الإرسال مقتصر على المدير العام فقط.',
'every captain': 'كل كابتن',
'every passenger': 'كل راكب',
'Enter both a title and a message.': 'أدخل عنواناً ونصاً للرسالة.',
'Send this notification to': 'إرسال هذا الإشعار إلى',
'on the platform?': 'على المنصة؟',
'It is delivered immediately and cannot be recalled.': 'يُسلَّم فوراً ولا يمكن استرجاعه.',
'Sent to': 'أُرسل إلى',
'at': 'في',
'Notification delivered to': 'تم تسليم الإشعار إلى',
'Send failed:': 'فشل الإرسال:',
'Notification title': 'عنوان الإشعار',
'Message text': 'نص الرسالة',
// Tariff
'Tariff': 'التعرفة',
'row #': 'صف #',
'Only a super admin can change pricing.': 'المدير العام فقط يمكنه تغيير الأسعار.',
'Nothing changed on this tariff row.': 'لم يتغيّر شيء في هذا السطر.',
'Apply these pricing changes to': 'تطبيق تغييرات التسعير على',
'This takes effect immediately for passengers.': 'يسري فوراً للركاب.',
'Tariff updated and recorded in the audit log.': 'تم تحديث التعرفة وتسجيلها في سجل العمليات.',
'Update failed:': 'فشل التحديث:',
// Diagnostics
'Running…': 'جارٍ التشخيص…',
'Console build:': 'إصدار المنصة:',
'MISSING — not signed in': 'غير موجود — لم يُسجَّل الدخول',
'NETWORK FAILURE —': 'فشل الشبكة —',
'(blocked by CORS, DNS, mixed content, or the host is unreachable)': '(محظور بواسطة CORS أو DNS أو محتوى مختلط أو الخادم غير متاح)',
'Run diagnostics': 'تشغيل التشخيص',
'(empty response body)': '(استجابة فارغة)',
'Custom…': 'مخصص…',
// WhatsApp
'Sending messages is restricted to super admins.': 'إرسال الرسائل مقتصر على المدير العام فقط.',
'Enter both a recipient and a message.': 'أدخل المستلم والرسالة.',
'Send this WhatsApp message to': 'إرسال رسالة واتساب إلى',
'Message sent.': 'تم إرسال الرسالة.',
// Crypto tool
'This tool is restricted to super admins.': 'هذه الأداة مقتصرة على المدير العام فقط.',
'Enter both your admin phone and the value.': 'أدخل هاتف المشرف والقيمة المراد فك/تشفيرها.',
// API base
'API base set to': 'تم تغيير عنوان API إلى',
'Reloading data…': 'جارٍ إعادة تحميل البيانات…',
'Diagnostics report copied.': 'تم نسخ تقرير التشخيص.',
'Copy failed — select the text manually.': 'فشل النسخ — حدد النص يدوياً.',
// Charts
'No ride data': 'لا توجد بيانات رحلات',
'TOTAL RIDES': 'إجمالي الرحلات',
// Helpers
'unrated': 'بدون تقييم',
// Login error
'The server accepted the request but issued no code and no token.': 'قبل الخادم الطلب لكنه لم يُصدر رمزاً ولا توكن.',
'Enter the 3-digit code.': 'أدخل الرمز المكوّن من ٣ أرقام.',
'Verifying…': 'جارٍ التحقق…',
'Session ended: ': 'انتهت الجلسة: ',
'n/a': 'غير متاح',
'You have been signed out.': 'تم تسجيل خروجك.',
// Module panel titles
'Realtime counters': 'عدادات مباشرة',
'Smart alerts': 'تنبيهات ذكية',
'Financial stats': 'إحصائيات مالية',
'Settlements': 'تسويات',
'Market share': 'الحصة السوقية',
'Price comparison': 'مقارنة الأسعار',
'Market anomalies': 'شذوذات السوق',
'Surge opportunity index': 'مؤشر فرصة الارتفاع',
'Win-back hotspots': 'نقاط استعادة العملاء',
'Campaign log': 'سجل الحملات',
'Pricing stability log': 'سجل استقرار الأسعار',
'AI price prediction': 'تنبؤ الأسعار بالذكاء الاصطناعي',
'Price gap heatmap': 'خريطة فروقات الأسعار',
'Telemetry': 'القياس عن بُعد',
'Promo codes': 'أكواد الخصم',
'Heatmap': 'الخريطة الحرارية',
'Best captains': 'أفضل الكابتنات',
'Card charges per captain': 'رسوم البطاقة لكل كابتن',
'Invoice totals': 'إجمالي الفواتير',
'Scorecard': 'بطاقة التقييم',
'Audit entries': 'سجلات العمليات',
'Recent errors': 'الأخطاء الأخيرة',
// Module subtitles
'Realtime fleet counters and the alerts that need attention now': 'عدادات الأسطول المباشرة والتنبيهات التي تحتاج انتباهاً الآن',
'Settlement runs and financial aggregates': 'تسويات ومجموعات مالية',
'Market share, competitor price gaps, anomalies and campaign history': 'الحصة السوقية وفروقات أسعار المنافسين والشذوذات وسجل الحملات',
'Stability log, AI predictions and the live price-gap heatmap': 'سجل الاستقرار وتنبؤات الذكاء الاصطناعي وخريطة فروقات الأسعار المباشرة',
'Universities, schools and companies running their own transport — open one to manage it': 'جامعات ومدارس وشركات تشغّل نقلها الخاص — افتح مؤسسة لإدارتها',
'Draft routes submitted by organisations, awaiting a decision': 'مسارات مقدّمة من المؤسسات بانتظار القرار',
// Org admin
'Enter both a name and a phone number.': 'أدخل الاسم ورقم الهاتف.',
'Give administrator access to this organisation?': 'منح صلاحية المشرف لهذه المؤسسة؟',
'Administrator added.': 'تمت إضافة المشرف.',
'Enable this administrator?': 'تفعيل هذا المشرف؟',
'Disable this administrator?': 'تعطيل هذا المشرف؟',
'Administrator enabled.': 'تم تفعيل المشرف.',
'Administrator disabled.': 'تم تعطيل المشرف.',
'Change the contract from': 'تغيير العقد من',
'Suspending stops the organisation using the service.': 'الإيقاف يمنع المؤسسة من استخدام الخدمة.',
'Organisation updated.': 'تم تحديث المؤسسة.',
'Organisation created.': 'تم إنشاء المؤسسة.',
'Name, founding admin name and phone are required.': 'الاسم واسم المدير المؤسس والهاتف مطلوبون.',
// Route approvals
'route': 'مسار',
'approveed': 'تم اعتماده',
'click to expand': 'اضغط للتوسيع',
'from this organisation': 'من هذه المؤسسة',
// Broadcast
'every captain': 'كل كابتن',
'every passenger': 'كل راكب',
'Sending…': 'جارٍ الإرسال…',
'It is delivered immediately and cannot be recalled.': 'يُسلَّم فوراً ولا يمكن استرجاعه.',
// Tariff
'Tariff': 'التعرفة',
'row #': 'صف #',
'Apply these pricing changes to': 'تطبيق تغييرات التسعير على',
'This takes effect immediately for passengers.': 'يسري فوراً للركاب.',
// Staff
'account': 'حساب',
'Account': 'حساب',
'activated.': 'تم تفعيله.',
'for': 'لـ',
'Phone:': 'الهاتف:',
'Email:': 'البريد:',
'ADMINISTRATOR': 'مدير',
'customer service': 'خدمة عملاء',
// Lift block
'Lift the block on': 'رفع الحظر عن',
// Campaign
'Launched at': 'أُطلقت في',
// Crypto / diagnostics
'Failed:': 'فشل:',
'present (role ': 'موجود (دور ',
'Siro Admin diagnostics —': 'تشخيص سيرو أدمن —',
// Blacklist
'on': 'على',
'captain': 'كابتن',
'passenger': 'راكب',
// Tariff labels
'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': 'أوفر',
// Platform commission AR labels
'normal': 'عادي',
'peak': 'ذروة',
'late_night': 'ليل',
// Transit org
'Organisation #': 'مؤسسة #',
// Ternary conversions
'trip': 'رحلة',
'Searching rides for': 'جارٍ البحث عن رحلات لـ',
'Searching captains for': 'جارٍ البحث عن كباتن لـ',
'Searching passengers for': 'جارٍ البحث عن ركاب لـ',
'days had at least one signup': 'يوماً بها تسجيل واحد على الأقل',
'Organisation': 'مؤسسة',
'required': 'إلزامي',
'Name (Arabic)': 'الاسم (عربي)',
'Name (English)': 'الاسم (إنجليزي)',
'2 letters': 'حرفين',
'Founding admin name': 'اسم المدير المؤسس',
'Founding admin phone': 'هاتف المدير المؤسس',
'Contact email': 'البريد الإلكتروني',
'Website': 'الموقع الإلكتروني',
'Trial ends': 'انتهاء الفترة التجريبية',
'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.': 'يتم حذف حساب السائق وإضافة رقم الهاتف إلى القائمة السوداء حتى لا يتمكن من التسجيل مرة أخرى. لا يمكن التراجع عن هذا الإجراء — فقط رفع الحظر، الذي لا يُعيد الحساب المحذوف.',
'required for captains': 'إلزامي للكباتن',
'Recorded on the blacklist entry': 'يُسجَّل في سبب القائمة السوداء',
'safety check': 'فحص أمان',
'Removes the phone number from the blacklist so it can register again. It does not restore a deleted account.': 'يزيل رقم الهاتف من القائمة السوداء حتى يتمكن من التسجيل مرة أخرى. لا يُعيد الحساب المحذوف.',
'Launching creates a real discount code and notifies every passenger in the selected country.': 'الإطلاق ينشئ كود خصم حقيقي ويخبر كل راكب في الدولة المحددة.',
'The promo stays valid for seven days. Always preview first.': 'يبقى كود الخصم صالحاً لمدة سبعة أيام. عاين دائماً أولاً.',
'defaults to the capital': 'العاصمة افتراضياً',
'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.': 'كل تطبيق يقارن إصداره بهذا الرقم عند التشغيل. رفعه يجبر كل مستخدم على التحديث قبل المتابعة.',
'Current version': 'الإصدار الحالي',
'showing': 'عرض',
'from': 'من',
'Documents': 'الوثائق',
'no file linked': 'لا ملف مرتبط',
'This captain has uploaded no documents — approving now would activate an unverified account.': 'هذا الكابتن لم يُرسل أي وثائق — الاعتماد الآن يعني تفعيل حساب غير موثّق.',
'Record': 'السجل',
'Creates a login for the Siro admin tools. Choose the password with the new member present, or have them change it at first sign-in — it is stored hashed and cannot be read back.': 'يُنشئ حساب دخول لأدوات سير للمشرفين. اختر كلمة المرور مع العضو الجديد، أو دعهم يغيّروها عند أول تسجيل دخول — هي مشفرة ولا يمكن قراءتها.',
'Customer service': 'خدمة العملاء',
'Administrator': 'مدير',
'Full name': 'الاسم الكامل',
'No accounts are waiting for activation.': 'لا توجد حسابات بانتظار التفعيل.',
'Unnamed route': 'مسار بدون اسم',
'unknown organisation': 'مؤسسة غير معروفة',
'min': 'دقيقة',
'Unnamed stop': 'محطة بدون اسم',
'This reaches every device at once and cannot be recalled.': 'هذا يصل لكل الأجهزة دفعة واحدة ولا يمكن استرجاعه.',
'The message is recorded in the audit log against your account.': 'يُسجَّل الرسالة في سجل العمليات ضد حسابك.',
'max 120': 'حد أقصى 120',
'Message': 'الرسالة',
'max 1000': 'حد أقصى 1000',
'No tariff rows configured.': 'لا توجد صفوف تعرفة معدّة.',
'You are signed in as an admin, so the tariff is shown read-only. Only a super admin can change prices.': 'أنت مسجّل كمدير، لذا تُعرض التعرفة للقراءة فقط. فقط المدير العام يمكنه تغيير الأسعار.',
'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.': 'هذه القيم حيّة. الحفظ يغيّر ما يدفعه كل راكب من الرحلة التالية فصاعداً. يُسجَّل التغيير في سجل العمليات ضد حسابك.',
'Reset': 'إعادة تعيين',
'Review & save': 'مراجعة وحفظ',
};
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 = `<i class="ph ph-arrows-clockwise"></i> <span>${t('Reload')}</span>`;
}
});
}
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('<!DOCTYPE') || preview.includes('<html')
? t(' — the endpoint returned HTML, check the API base URL')
: '';
throw new ApiError(t('Request failed') + ` (HTTP ${res.status})${hint}`, res.status);
}
if (res.status === 401 || res.status === 403) {
throw new ApiError(pickMessage(json) || 'Session rejected by the server', res.status);
}
if (json.status === 'failure' || json.error) {
throw new ApiError(pickMessage(json) || 'Request failed', res.status);
}
// jsonSuccess()/printSuccess() put the payload in `message`; a few older
// endpoints use `data`.
return json.message !== undefined ? json.message : (json.data !== undefined ? json.data : json);
}
class ApiError extends Error {
constructor(message, status) { super(message); this.status = status; }
}
function pickMessage(json) {
if (!json) return null;
if (typeof json.message === 'string') return json.message;
if (typeof json.error === 'string') return json.error;
if (json.message && typeof json.message.message === 'string') return json.message.message;
return null;
}
function handleApiError(err, context) {
console.error(`[${context}]`, err);
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
signOut(t('Session ended: ') + err.message);
return true;
}
setConnection('error', t('Data unavailable'));
return false;
}
// ── Authentication ───────────────────────────────────────────────────────
let pendingPhone = '';
function setupAuthEvents() {
el.loginForm?.addEventListener('submit', onLogin);
el.submitOtpBtn?.addEventListener('click', onVerifyOtp);
el.otpInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') onVerifyOtp(); });
el.logoutBtn?.addEventListener('click', () => signOut(t('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 = `<i class="ph-fill ph-warning-octagon"></i><div>
<strong>${esc(message)}</strong>
${detail ? `<span>${esc(detail)}</span>` : ''}
</div>`;
}
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 = t('Enter the 3-digit code sent to ') + (payload.phone || t('your WhatsApp')) + '.';
el.otpModal.classList.add('active');
el.otpInput.value = '';
el.otpInput.focus();
toast(payload.message || t('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(t('Enter the 3-digit code.'), 'warning');
return;
}
busy(el.submitOtpBtn, true, t('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(t('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(t('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 = t('No data — check diagnostics');
toast(t('Every endpoint failed.') + ' ' + t('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) => `
<div class="mix-row">
<div class="mix-head">
<span>${t.label}</span>
<strong>${fmtInt(t.value)} <span class="mix-pct">${((t.value / total) * 100).toFixed(1)}%</span></strong>
</div>
<div class="mix-track"><div class="mix-fill" style="width:${(t.value / total) * 100}%; background:${t.color};"></div></div>
</div>
`).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]) => `
<div class="kv-row"><span>${k}</span><strong>${v}</strong></div>
`).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 ? t('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) => `
<tr>
<td><strong>#${esc(r.id)}</strong></td>
<td>${esc(r.passenger_full_name || t('Unknown'))}</td>
<td>${esc(r.driver_full_name || t('Unassigned'))}</td>
<td class="route-cell">${routeCell(r)}</td>
<td><strong>${fmtMoney(rideFare(r))}</strong></td>
<td><span class="badge ${badgeClass(r.status)}">${esc(labelStatus(r.status))}</span></td>
<td>${esc(rideTimestamp(r))}</td>
<td><button class="btn btn-secondary btn-sm" data-ride="${esc(r.id)}"><i class="ph ph-eye"></i></button></td>
</tr>`).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 `<i class="ph ph-map-pin"></i> ${esc(shorten(r.address_start))} <i class="ph ph-arrow-right"></i> ${esc(shorten(r.address_end))}`;
}
if (r.start_location) {
const link = mapLink(r.start_location);
return `<a class="coord-link" href="${esc(link)}" target="_blank" rel="noopener">
<i class="ph ph-map-pin"></i> ${esc(shortCoord(r.start_location))} <i class="ph ph-arrow-right"></i> ${esc(shortCoord(r.end_location))}
</a>`;
}
return '<span class="stamp">—</span>';
}
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, `${t('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, `${t('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 = t('Search results for') + ` “${term}”`;
if (!rows.length) tableMessage(el.driversTableBody, 8, t('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, `${t('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, t('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(t('Captain #') + id, () =>
api('/Admin/AdminCaptain/getCaptainDetailsById.php', { params: { driver_id: id } }));
}
async function openPassengerProfile(id) {
openProfile(t('Passenger #') + id, () =>
api('/Admin/getPassengerDetailsByPassengerID.php', { params: { passengerID: id } }));
}
async function openProfile(title, fetcher) {
const body = $('modalBodyContent');
body.innerHTML = `
<div class="modal-head">
<h3>${esc(title)}</h3>
<button class="btn-icon" onclick="closeModal()"><i class="ph ph-x"></i></button>
</div>
<div class="table-msg">${t('Loading profile…')}</div>`;
$('detailsModal').classList.add('active');
try {
const payload = await fetcher();
const record = normaliseRows(payload)[0];
if (!record) throw new ApiError(t('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 = `
<div class="modal-head">
<h3>${esc(title)}</h3>
<button class="btn-icon" onclick="closeModal()"><i class="ph ph-x"></i></button>
</div>
<div class="mini-list">
${entries.map(([k, v]) => `<div class="kv-row"><span>${esc(k)}</span><strong>${esc(String(v))}</strong></div>`).join('')}
</div>`;
} catch (err) {
if (handleApiError(err, 'profile')) return;
body.innerHTML = `
<div class="modal-head">
<h3>${esc(title)}</h3>
<button class="btn-icon" onclick="closeModal()"><i class="ph ph-x"></i></button>
</div>
<div class="table-msg is-error">${esc(err.message)}</div>`;
}
}
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) => `
<tr class="row-clickable" data-captain="${esc(d.id)}">
<td><strong>#${esc(d.id)}</strong></td>
<td>${esc(`${d.first_name || ''} ${d.last_name || ''}`.trim() || t('Unnamed'))}</td>
<td>${esc(maskPhone(d.phone))}</td>
<td>${esc(d.email || '—')}</td>
<td>${rating(d.passengerAverageRating)}</td>
<td>${fmtInt(d.countPassengerRide)}</td>
<td>${fmtInt(d.countPassengerCancel)}</td>
<td><span class="badge ${badgeClass(d.status)}">${esc(d.status || t('Unknown'))}</span></td>
</tr>
`).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) => `
<tr class="row-clickable" data-passenger="${esc(p.id)}">
<td><strong>#${esc(p.id)}</strong></td>
<td>${esc(`${p.first_name || ''} ${p.last_name || ''}`.trim() || t('Unnamed'))}</td>
<td>${esc(p.email || maskPhone(p.phone))}</td>
<td>${fmtInt(p.countPassengerRide)}</td>
<td>${rating(p.passengerAverageRating)}</td>
<td>${fmtInt(p.countPassengerCancel)}</td>
<td>${esc(fmtDate(p.created_at, true))}</td>
<td><span class="badge ${badgeClass(p.status)}">${esc(p.status || t('Unknown'))}</span></td>
</tr>
`).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) => `
<tr>
<td><strong>#${esc(p.id)}</strong></td>
<td>${esc(p.name || '—')}</td>
<td>${esc(p.phone || '—')}</td>
<td><span class="badge badge-info">${esc(p.type)}</span></td>
<td>${esc(p.role || '—')}</td>
<td>${esc(fmtDate(p.created_at, true))}</td>
<td>${(isSuper && p.type === 'admin') ? `
<button class="btn btn-secondary btn-sm" data-approve="${esc(p.id)}"><i class="ph ph-check"></i> ${t('Approve')}</button>
<button class="btn btn-secondary btn-sm danger-btn" data-reject="${esc(p.id)}"><i class="ph ph-x"></i></button>
` : `<span class="stamp">${t('super admin only')}</span>`}</td>
</tr>
`).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(t('Set admin') + ` #${adminId} ` + t('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(`${t('Admin')} #${adminId} ${t(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]) =>
`<div class="kv-row"><span>${k}</span><strong>${esc(String(v))}</strong></div>`).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 = `<i class="ph ${mod.icon}"></i><span>${esc(t(mod.title))}</span>`;
menu.appendChild(item);
const section = document.createElement('section');
section.className = 'page-view';
section.id = `mod_${mod.id}`;
section.innerHTML = `
<div class="page-header">
<div class="page-title">
<h1>${esc(t(mod.title))}</h1>
<p>${esc(t(mod.subtitle))}</p>
</div>
<div class="page-actions">
<button class="btn btn-secondary btn-sm" data-reload="${mod.id}">
<i class="ph ph-arrows-clockwise"></i> ${t('Reload')}
</button>
</div>
</div>
<div class="module-panels" id="panels_${mod.id}"></div>`;
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) => `
<div class="card" data-panel="${esc(p.path)}">
<div class="card-header"><h3 class="card-title">${esc(t(p.title))}</h3></div>
<div class="panel-body"><div class="table-msg">${t('Loading…')}</div></div>
</div>`).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 = `<div class="table-msg is-error">${esc(err.message)}</div>`;
}
}));
}
// 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) => `
<div class="card">
<div class="card-header"><h3 class="card-title">${esc(tool.title)}</h3></div>
<div class="api-base-row">
<input type="text" class="form-input" data-lookup-input="${i}" placeholder="${esc(tool.placeholder)}" dir="ltr">
<button class="btn btn-secondary btn-sm" data-lookup-run="${i}"><i class="ph ph-magnifying-glass"></i> <span>${t('Look up')}</span></button>
</div>
<div class="panel-body" data-lookup-body="${i}">
<div class="table-msg">${t('Enter a value above to query this endpoint.')}</div>
</div>
</div>`).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 = `<div class="table-msg">${t('Querying…')}</div>`;
try {
const payload = await api(tool.path, { params: { [tool.field]: value } });
renderPayload(body, payload);
} catch (err) {
if (handleApiError(err, 'lookup')) return;
body.innerHTML = `<div class="table-msg is-error">${esc(err.message)}</div>`;
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 = `<div class="card"><div class="table-msg">${t('Loading growth…')}</div></div>`;
let payload;
try {
payload = await api('/Admin/v2/analytics/growth.php');
} catch (err) {
if (handleApiError(err, 'growth')) return;
host.innerHTML = `<div class="card"><div class="table-msg is-error">${esc(err.message)}</div></div>`;
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 = `
<div class="stats-grid">
${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')}
</div>
<div class="card">
<div class="card-header">
<h3 class="card-title">${t('Signups per day')} <span class="card-sub">${t('last 30 days')}</span></h3>
<div class="legend" style="margin:0;">
<span class="legend-item"><i style="background:#6366f1"></i>${t('Passengers')}</span>
<span class="legend-item"><i style="background:#10b981"></i>${t('Captains')}</span>
</div>
</div>
<div class="chart-container"><canvas id="growthChart"></canvas></div>
${activeDays === 0
? `<div class="table-msg">${t('Nobody signed up in the last 30 days.')}</div>`
: `<div class="stamp" style="display:block;text-align:center;margin-top:0.5rem;">
${activeDays} / 30 ${t('days had at least one signup')}
</div>`}
</div>`;
drawMultiLine('growthChart', [
{ label: t('Passengers'), color: '#6366f1', points: passengers },
{ label: t('Captains'), color: '#10b981', points: drivers },
]);
}
function growthTile(value, label, icon, tone) {
return `
<div class="stat-card">
<div class="stat-header">
<div>
<div class="stat-value">${esc(value)}</div>
<div class="stat-title">${esc(label)}</div>
</div>
<div class="stat-icon ${tone}"><i class="ph ${icon}"></i></div>
</div>
</div>`;
}
// ── Advanced analytics ───────────────────────────────────────────────────
async function renderAnalytics(host) {
host.innerHTML = `
<div class="card notice-card">
<i class="ph-fill ph-info"></i>
<span>${t('Revenue is counted from completed rides only.')}</span>
</div>
<div class="card" id="anRevenue"><div class="table-msg">${t('Loading revenue…')}</div></div>
<div class="card" id="anRanking"><div class="table-msg">${t('Loading captain ranking…')}</div></div>`;
// 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 = `
<div class="card-header">
<h3 class="card-title">${t('Revenue')} <span class="card-sub">${t('last 30 days')}</span></h3>
</div>
<div class="kpi-tiles">
<div class="kpi-tile"><div class="kpi-tile-value">${summary.total_revenue_all == null ? '—' : fmtMoney(summary.total_revenue_all)}</div><div class="kpi-tile-label">${t('Total collected')}</div></div>
<div class="kpi-tile"><div class="kpi-tile-value">${summary.total_profit_all == null ? '—' : fmtMoney(summary.total_profit_all)}</div><div class="kpi-tile-label">${t('Platform share')}</div></div>
<div class="kpi-tile"><div class="kpi-tile-value">${summary.avg_ride_price == null ? '—' : fmtMoney(summary.avg_ride_price)}</div><div class="kpi-tile-label">${t('Average fare')}</div></div>
<div class="kpi-tile"><div class="kpi-tile-value">${fmtInt(sumSeries(rides))}</div><div class="kpi-tile-label">${t('Completed rides')}</div></div>
</div>
<div class="chart-container" style="height:220px;margin-top:1rem;"><canvas id="revenueTrend"></canvas></div>
${sumSeries(series) === 0 ? `<div class="table-msg">${t('No completed rides carried a fare in this period.')}</div>` : ''}`;
drawMultiLine('revenueTrend', [{ label: t('Revenue'), color: '#06b6d4', points: series }]);
} catch (err) {
if (handleApiError(err, 'analytics-revenue')) return;
$('anRevenue').innerHTML = `<div class="table-msg is-error">${esc(err.message)}</div>`;
}
// Captain ranking
try {
const ranking = await api('/Admin/v2/analytics/driver_ranking.php');
const body = $('anRanking');
body.innerHTML = `<div class="card-header"><h3 class="card-title">${t('Captain ranking')}</h3></div><div class="panel-body"></div>`;
renderPayload(body.querySelector('.panel-body'), ranking);
} catch (err) {
if (handleApiError(err, 'analytics-ranking')) return;
$('anRanking').innerHTML = `<div class="table-msg is-error">${esc(err.message)}</div>`;
}
}
// ── 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 = `
<div class="filter-bar">
<div class="search-inline">
<input type="text" class="form-input" id="orgSearch" placeholder="${t('Search by name')}" dir="auto">
<button class="btn btn-secondary btn-sm" id="orgSearchBtn"><i class="ph ph-magnifying-glass"></i></button>
</div>
<button class="btn btn-primary btn-sm" id="orgNewBtn"><i class="ph ph-plus"></i> <span>${t('New organisation')}</span></button>
</div>
<div class="card" id="orgList"><div class="table-msg">${t('Loading organisations…')}</div></div>`;
$('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 = `<div class="table-msg">${t('Loading organisations…')}</div>`;
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 = `<div class="table-msg">${t('No organisations registered yet.')}</div>`;
return;
}
panel.innerHTML = `
<div class="card-header">
<h3 class="card-title">${t('Organisations')} <span class="card-sub">${fmtInt(total)} ${t('total')}</span></h3>
</div>
<div class="table-responsive">
<table class="data-table">
<thead><tr>
<th>${t('Name')}</th><th>${t('Type')}</th><th>${t('City')}</th><th>${t('Contract')}</th>
<th>${t('Captains')}</th><th>${t('Vehicles')}</th><th>${t('Routes')}</th><th>${t('Enrolled')}</th><th></th>
</tr></thead>
<tbody>
${orgs.map((o, i) => `
<tr class="row-clickable" data-org="${esc(o.id)}">
<td><strong>${esc(o.name_ar || o.name_en || '—')}</strong></td>
<td>${esc(humanize(o.type || '—'))}</td>
<td>${esc(o.city || '—')}</td>
<td><span class="badge ${badgeClass(o.contract_status)}">${esc(humanize(o.contract_status || 'unknown'))}</span></td>
<td>${fmtInt(o.drivers_count)}</td>
<td>${fmtInt(o.vehicles_count)}</td>
<td>${fmtInt(o.active_routes)}</td>
<td>${fmtInt(o.active_enrollments)}</td>
<td><button class="btn btn-secondary btn-sm" data-org-edit="${i}"><i class="ph ph-pencil-simple"></i></button></td>
</tr>`).join('')}
</tbody>
</table>
</div>`;
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 = `<div class="table-msg is-error">${esc(err.message)}</div>`;
}
}
async function openOrgDetails(orgId, host) {
const body = $('modalBodyContent');
body.innerHTML = `<div class="table-msg">${t('Loading…')}</div>`;
$('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 = `
<div class="modal-head">
<h3>${esc(org.name_ar || org.name_en || `Organisation #${orgId}`)}</h3>
<button class="btn-icon" onclick="closeModal()"><i class="ph ph-x"></i></button>
</div>
<div class="kpi-tiles">
${Object.entries(counts).map(([k, v]) =>
`<div class="kpi-tile"><div class="kpi-tile-value">${esc(formatValue(v, k))}</div><div class="kpi-tile-label">${esc(humanize(k))}</div></div>`).join('')}
</div>
<div class="sub-panel">
<h4 class="sub-panel-title">${t('Routes')} (${routes.length})</h4>
${routes.length ? `<div class="mini-list">${routes.map((r) => `
<div class="kv-row">
<span>${esc(r.name_ar || r.name_en || `#${r.id}`)}</span>
<strong><span class="badge ${badgeClass(r.status)}">${esc(humanize(r.status || ''))}</span></strong>
</div>`).join('')}</div>` : `<div class="table-msg">${t('No routes yet.')}</div>`}
</div>
<div class="sub-panel">
<h4 class="sub-panel-title">${t('Recent trips')} (${trips.length})</h4>
<div class="panel-body" id="orgTrips"></div>
</div>
<div class="sub-panel">
<h4 class="sub-panel-title">${t('Administrators')}</h4>
<div class="panel-body" id="orgAdmins"><div class="table-msg">${t('Loading…')}</div></div>
<div class="api-base-row" style="margin-top:0.75rem;">
<input type="text" class="form-input" id="orgAdminName" placeholder="${t('Name')}">
<input type="text" class="form-input" id="orgAdminPhone" placeholder="${t('Phone')}">
<button class="btn btn-secondary btn-sm" id="orgAdminAdd"><i class="ph ph-user-plus"></i> ${t('Add')}</button>
</div>
</div>`;
renderPayload($('orgTrips'), trips);
loadOrgAdmins(orgId);
$('orgAdminAdd').addEventListener('click', () => addOrgAdmin(orgId));
} catch (err) {
if (handleApiError(err, 'org-details')) return;
body.innerHTML = `<div class="modal-head"><h3>${t('Organisation')}</h3>
<button class="btn-icon" onclick="closeModal()"><i class="ph ph-x"></i></button></div>
<div class="table-msg is-error">${esc(err.message)}</div>`;
}
}
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 = `<div class="table-msg">${t('No administrators yet.')}</div>`;
return;
}
panel.innerHTML = `<div class="mini-list">${admins.map((a) => `
<div class="kv-row">
<span>${esc(a.name || '—')} <span class="stamp">${esc(humanize(a.role || ''))}</span></span>
<strong>
<button class="btn btn-secondary btn-sm" data-admin-toggle="${esc(a.id)}" data-active="${Number(a.is_active) ? 1 : 0}">
${Number(a.is_active) ? `<i class="ph ph-pause"></i> ${t('Disable')}` : `<i class="ph ph-play"></i> ${t('Enable')}`}
</button>
</strong>
</div>`).join('')}</div>`;
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 = `<div class="table-msg is-error">${esc(err.message)}</div>`;
}
}
async function addOrgAdmin(orgId) {
const name = $('orgAdminName').value.trim();
const phone = $('orgAdminPhone').value.trim();
if (!name || !phone) {
toast(t('Enter both a name and a phone number.'), 'warning');
return;
}
if (!confirm(`${t('Give administrator access to this organisation?')}\n\n${name} (${phone})`)) return;
try {
await api('/Admin/transit/org/admin_add.php', { params: { org_id: orgId, name, phone } });
toast(t('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 ? t('Enable this administrator?') : t('Disable this administrator?'))) return;
try {
await api('/Admin/transit/org/admin_toggle.php', { params: { admin_id: adminId, is_active: isActive } });
toast(isActive ? t('Administrator enabled.') : t('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 = `
<div class="modal-head">
<h3>${editing ? esc(org.name_ar || t('Edit organisation')) : t('New organisation')}</h3>
<button class="btn-icon" onclick="closeModal()"><i class="ph ph-x"></i></button>
</div>
<div class="tariff-grid">
${editing ? '' : `
<label class="tariff-field">
<span class="tariff-label">${t('Type')} <em>${t('required')}</em></span>
<select class="select-input" id="orgType">
${ORG_TYPES.map((t) => `<option value="${t}">${humanize(t)}</option>`).join('')}
</select>
</label>
<label class="tariff-field">
<span class="tariff-label">${t('Name (Arabic)')} <em>${t('required')}</em></span>
<input type="text" class="form-input" id="orgNameAr">
</label>
<label class="tariff-field">
<span class="tariff-label">${t('Name (English)')}</span>
<input type="text" class="form-input" id="orgNameEn">
</label>
<label class="tariff-field">
<span class="tariff-label">${t('Country')} <em>${t('2 letters')}</em></span>
<input type="text" class="form-input" id="orgCountry" value="JO" maxlength="2">
</label>
<label class="tariff-field">
<span class="tariff-label">${t('Founding admin name')} <em>${t('required')}</em></span>
<input type="text" class="form-input" id="orgAdminNameNew">
</label>
<label class="tariff-field">
<span class="tariff-label">${t('Founding admin phone')} <em>${t('required')}</em></span>
<input type="text" class="form-input" id="orgAdminPhoneNew">
</label>`}
<label class="tariff-field">
<span class="tariff-label">${t('City')}</span>
<input type="text" class="form-input" id="orgCity" value="${editing ? esc(org.city ?? '') : ''}">
</label>
${editing ? `
<label class="tariff-field">
<span class="tariff-label">${t('Contact email')}</span>
<input type="email" class="form-input" id="orgEmail" value="${esc(org.contact_email ?? '')}">
</label>
<label class="tariff-field">
<span class="tariff-label">${t('Website')}</span>
<input type="text" class="form-input" id="orgWebsite" value="${esc(org.website ?? '')}">
</label>
<label class="tariff-field">
<span class="tariff-label">${t('Contract')}</span>
<select class="select-input" id="orgContract">
${CONTRACT_STATES.map((c) => `<option value="${c}" ${org.contract_status === c ? 'selected' : ''}>${humanize(c)}</option>`).join('')}
</select>
</label>
<label class="tariff-field">
<span class="tariff-label">${t('Trial ends')}</span>
<input type="date" class="form-input" id="orgTrialEnds" value="${esc((org.trial_ends_at || '').slice(0, 10))}">
</label>` : ''}
</div>
<div class="api-base-row" style="margin-top:1rem;">
<button class="btn btn-primary btn-sm" id="orgSave">
<i class="ph ph-floppy-disk"></i> <span>${editing ? t('Save changes') : t('Create organisation')}</span>
</button>
</div>`;
$('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(t('Change the contract from') + ` "${org.contract_status}" ${t('to')} "${params.contract_status}"? ` +
t('Suspending stops the organisation using the service.'))) return;
await api('/Admin/transit/org/update.php', { params });
toast(t('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(t('Name, founding admin name and phone are required.'), 'warning');
return;
}
await api('/Admin/transit/org/create.php', { params });
toast(t('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 = `
<div class="card notice-card notice-danger">
<i class="ph-fill ph-warning-octagon"></i>
<span><strong>${t('Removal is permanent.')}</strong>
${t('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.')}</span>
</div>
<div class="card" id="blacklistCurrent"><div class="table-msg">${t('Loading blacklist…')}</div></div>
<div class="card">
<div class="card-header"><h3 class="card-title">${t('Remove & blacklist')}</h3></div>
<div class="tariff-grid">
<label class="tariff-field">
<span class="tariff-label">${t('Account type')}</span>
<select class="select-input" id="rmType">
<option value="passenger">${t('Passenger')}</option>
<option value="driver">${t('Captain')}</option>
</select>
</label>
<label class="tariff-field">
<span class="tariff-label">${t('Phone')} <em>${t('required')}</em></span>
<input type="text" class="form-input" id="rmPhone" autocomplete="off">
</label>
<label class="tariff-field">
<span class="tariff-label">${t('Account ID')} <em>${t('required for captains')}</em></span>
<input type="text" class="form-input" id="rmId" autocomplete="off">
</label>
<label class="tariff-field">
<span class="tariff-label">${t('Reason')}</span>
<input type="text" class="form-input" id="rmReason" placeholder="${t('Recorded on the blacklist entry')}">
</label>
<label class="tariff-field">
<span class="tariff-label">${t('Retype the phone to confirm')} <em>${t('safety check')}</em></span>
<input type="text" class="form-input" id="rmConfirmPhone" autocomplete="off">
</label>
</div>
<div class="api-base-row" style="margin-top:1rem;">
<button class="btn btn-danger btn-sm" id="rmSubmit"><i class="ph ph-trash"></i> <span>${t('Delete and blacklist')}</span></button>
<span class="stamp" id="rmStatus"></span>
</div>
</div>
<div class="card">
<div class="card-header"><h3 class="card-title">${t('Lift a block')}</h3></div>
<p class="card-note">${t('Removes the phone number from the blacklist so it can register again. It does not restore a deleted account.')}</p>
<div class="tariff-grid">
<label class="tariff-field">
<span class="tariff-label">${t('Account type')}</span>
<select class="select-input" id="ubType">
<option value="passenger">${t('Passenger')}</option>
<option value="driver">${t('Captain')}</option>
</select>
</label>
<label class="tariff-field">
<span class="tariff-label">${t('Phone')}</span>
<input type="text" class="form-input" id="ubPhone" autocomplete="off">
</label>
</div>
<div class="api-base-row" style="margin-top:1rem;">
<button class="btn btn-secondary btn-sm" id="ubSubmit"><i class="ph ph-lock-open"></i> <span>${t('Lift block')}</span></button>
<span class="stamp" id="ubStatus"></span>
</div>
</div>`;
$('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 = `<div class="card-header"><h3 class="card-title">${t('Currently blocked')}</h3></div><div class="panel-body"></div>`;
renderPayload(panel.querySelector('.panel-body'), payload);
} catch (err) {
if (handleApiError(err, 'blacklist')) return;
panel.innerHTML = `<div class="card-header"><h3 class="card-title">${t('Currently blocked')}</h3></div><div class="table-msg is-error">${esc(err.message)}</div>`;
}
}
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(t('Enter the phone number of the account to remove.'), 'warning');
return;
}
if (confirmPhone !== phone) {
toast(t('The retyped phone does not match — nothing was deleted.'), 'danger');
return;
}
if (type === 'driver' && !id) {
toast(t('Removing a captain requires their account ID.'), 'warning');
return;
}
if (!confirm(
t('Permanently delete') + ` ${label} ${t('on')} ${phone} ` + t('and blacklist that number?') + '\n\n' +
`${reason ? t('Reason') + `: ${reason}\n\n` : ''}` +
t('The account is deleted from the database. This cannot be undone.')
)) return;
busy($('rmSubmit'), true, t('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 = t('Removed and blacklisted') + ` ${phone}`;
toast(label === 'captain' ? t('Captain removed and blacklisted.') : t('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, t('Delete and blacklist'));
}
}
async function liftBlock() {
const type = $('ubType').value;
const phone = $('ubPhone').value.trim();
const status = $('ubStatus');
if (!phone) {
toast(t('Enter the blocked phone number.'), 'warning');
return;
}
if (!confirm(t('Lift the block on') + ` ${phone}? ` + t('They will be able to register again.'))) return;
busy($('ubSubmit'), true, t('Working…'));
try {
const path = type === 'driver'
? '/Admin/driver/remove_from_blacklist.php'
: '/Admin/passenger/admin_unblacklist.php';
await api(path, { params: { phone } });
status.textContent = t('Block lifted for') + ` ${phone}`;
toast(t('Block lifted.'), 'success');
$('ubPhone').value = '';
loadBlacklist();
} catch (err) {
if (!handleApiError(err, 'lift-block')) toast(err.message, 'danger');
} finally {
busy($('ubSubmit'), false, t('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 = `
<div class="card notice-card notice-danger">
<i class="ph-fill ph-warning"></i>
<span><strong>${t('Launching creates a real discount code and notifies every passenger in the selected country.')}
${t('The promo stays valid for seven days. Always preview first.')}</strong></span>
</div>
<div class="card">
<div class="card-header"><h3 class="card-title">${t('Campaign parameters')}</h3></div>
<div class="tariff-grid">
<label class="tariff-field">
<span class="tariff-label">${t('Country')}</span>
<select class="select-input" id="cmpCountry">
<option value="JO">${t('Jordan')}</option>
<option value="SY">${t('Syria')}</option>
<option value="EG">${t('Egypt')}</option>
<option value="IQ">${t('Iraq')}</option>
</select>
</label>
<label class="tariff-field">
<span class="tariff-label">${t('Region')} <em>${t('defaults to the capital')}</em></span>
<input type="text" class="form-input" id="cmpRegion" placeholder="Amman">
</label>
<label class="tariff-field">
<span class="tariff-label">${t('Siro base price')}</span>
<input type="number" step="any" class="form-input" id="cmpBasePrice" value="1.25">
</label>
</div>
<div class="api-base-row" style="margin-top:1rem;">
<button class="btn btn-secondary btn-sm" id="cmpPreview"><i class="ph ph-eye"></i> <span>${t('Preview')}</span></button>
<button class="btn btn-primary btn-sm" id="cmpLaunch" disabled><i class="ph ph-rocket-launch"></i> <span>${t('Launch campaign')}</span></button>
<span class="stamp" id="cmpStatus">${t('Preview first to enable launching.')}</span>
</div>
</div>
<div class="card" id="cmpResult">
<div class="table-msg">${t('No analysis run yet.')}</div>
</div>
<div class="card" id="cmpLog"><div class="table-msg">${t('Loading campaign history…')}</div></div>`;
$('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(t('Parameters changed since the preview') + ' — ' + t('preview again before launching'), 'warning');
$('cmpLaunch').disabled = true;
return;
}
if (!confirm(
t('Launch this campaign in') + ` ${params.country_code}?\n\n` +
t('It creates a discount code valid for 7 days and pushes a notification to every passenger there.') + '\n\n' +
t('This cannot be undone.')
)) return;
}
const btn = isPreview ? $('cmpPreview') : $('cmpLaunch');
busy(btn, true, isPreview ? t('Analysing…') : t('Launching…'));
$('cmpResult').innerHTML = `<div class="table-msg">${t('Running market analysis…')}</div>`;
try {
const payload = await api('/Admin/marketing/trigger_campaign.php', {
params: isPreview ? { ...params, dry_run: '1' } : params,
});
$('cmpResult').innerHTML = `<div class="card-header"><h3 class="card-title">${isPreview ? t('Preview') : t('Launch result')}</h3></div><div class="panel-body"></div>`;
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
? t('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 = t('Launched at') + ` ${new Date().toLocaleTimeString()}`;
toast(t('Campaign dispatched.'), 'success');
loadCampaignLog();
}
} catch (err) {
if (!handleApiError(err, 'campaign')) {
$('cmpResult').innerHTML = `<div class="table-msg is-error">${esc(err.message)}</div>`;
toast(err.message, 'danger');
}
} finally {
busy(btn, false, isPreview ? t('Preview') : t('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 = `<div class="card-header"><h3 class="card-title">${t('Campaign history')}</h3></div><div class="panel-body"></div>`;
renderPayload(panel.querySelector('.panel-body'), payload);
} catch (err) {
if (handleApiError(err, 'campaign-log')) return;
panel.innerHTML = `<div class="card-header"><h3 class="card-title">${t('Campaign history')}</h3></div><div class="table-msg is-error">${esc(err.message)}</div>`;
}
}
// ── App versions ─────────────────────────────────────────────────────────
async function renderAppVersions(host) {
host.innerHTML = `<div class="card"><div class="table-msg">${t('Loading package versions…')}</div></div>`;
let packages = [];
try {
packages = normaliseRows(await api('/serviceapp/getPackages.php'));
} catch (err) {
if (handleApiError(err, 'packages')) return;
host.innerHTML = `<div class="card"><div class="table-msg is-error">${esc(err.message)}</div></div>`;
return;
}
if (!packages.length) {
host.innerHTML = `<div class="card"><div class="table-msg">${t('No package rows configured.')}</div></div>`;
return;
}
host.innerHTML = `
<div class="card notice-card">
<i class="ph-fill ph-info"></i>
<span>${t('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.')}</span>
</div>
${packages.map((pkg, index) => `
<div class="card">
<div class="card-header">
<h3 class="card-title">
${esc(pkg.name || pkg.packageName || pkg.app_name || `Package #${pkg.id}`)}
<span class="card-sub">#${esc(pkg.id)}</span>
</h3>
<button class="btn btn-primary btn-sm" data-pkg-save="${index}"><i class="ph ph-floppy-disk"></i> <span>${t('Update version')}</span></button>
</div>
<div class="tariff-grid">
<label class="tariff-field">
<span class="tariff-label">${t('Current version')}</span>
<input type="text" class="form-input" data-pkg-version="${index}" value="${esc(pkg.version ?? '')}">
</label>
</div>
</div>`).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(t('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(t('Use digits separated by dots, e.g. 1.4.2'), 'warning');
return;
}
if (!confirm(
t('Version set to') + ` ${pkg.name || `package #${pkg.id}`} \u2192 ${version} (${pkg.version ?? '—'})?\n\n` +
t('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(t('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 = `<div class="card"><div class="table-msg">${t('Loading captains awaiting review…')}</div></div>`;
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 = `<div class="card"><div class="table-msg is-error">${esc(err.message)}</div></div>`;
return;
}
if (!drivers.length && offset === 0) {
host.innerHTML = `<div class="card"><div class="table-msg">${t('No captains are awaiting document review.')}</div></div>`;
return;
}
host.innerHTML = `
<div class="card">
<div class="card-header">
<h3 class="card-title">${t('Awaiting review')} <span class="card-sub">${t('showing')} ${drivers.length} ${t('from')} #${offset + 1}</span></h3>
<div style="display:flex; gap:0.5rem;">
<button class="btn btn-secondary btn-sm" id="docsPrev" ${offset === 0 ? 'disabled' : ''}><i class="ph ph-caret-left"></i></button>
<button class="btn btn-secondary btn-sm" id="docsNext" ${drivers.length < DOCS_PAGE_SIZE ? 'disabled' : ''}><i class="ph ph-caret-right"></i></button>
</div>
</div>
<div class="table-responsive">
<table class="data-table">
<thead><tr><th>${t('ID')}</th><th>${t('Name')}</th><th>${t('Phone')}</th><th></th></tr></thead>
<tbody>
${drivers.map((d) => `
<tr>
<td><strong>#${esc(d.id)}</strong></td>
<td>${esc(`${d.first_name || ''} ${d.last_name || ''}`.trim() || t('Unnamed'))}</td>
<td>${esc(maskPhone(d.phone))}</td>
<td><button class="btn btn-secondary btn-sm" data-review="${esc(d.id)}"><i class="ph ph-files"></i> ${t('Review documents')}</button></td>
</tr>`).join('')}
</tbody>
</table>
</div>
</div>
<div class="card" id="docsDetail">
<div class="table-msg">${t('Pick a captain above to inspect their documents.')}</div>
</div>`;
$('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 = `<div class="table-msg">${t('Loading documents…')}</div>`;
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 = `<div class="table-msg is-error">${esc(err.message)}</div>`;
return;
}
const facts = Object.entries(driver)
.filter(([k, v]) => !/token|password|fingerprint/i.test(k) && v !== null && v !== '')
.slice(0, 18);
panel.innerHTML = `
<div class="card-header">
<h3 class="card-title">
${esc(`${driver.first_name || ''} ${driver.last_name || ''}`.trim() || t('Captain #') + driverId)}
<span class="card-sub">#${esc(driverId)} · ${esc(driver.status || t('Unknown'))}</span>
</h3>
<button class="btn btn-primary btn-sm" data-approve-driver="${esc(driverId)}">
<i class="ph ph-check"></i> <span>${t('Approve & activate')}</span>
</button>
</div>
<div class="sub-panel">
<h4 class="sub-panel-title">${t('Documents')} (${documents.length})</h4>
${documents.length ? `
<div class="doc-grid">
${documents.map((doc) => `
<figure class="doc-card">
${doc.link
? `<a href="${esc(doc.link)}" target="_blank" rel="noopener">
<img src="${esc(doc.link)}" alt="${esc(doc.doc_type || 'document')}" loading="lazy">
</a>`
: `<div class="doc-missing"><i class="ph ph-file-x"></i> ${t('no file linked')}</div>`}
<figcaption>
<strong>${esc(humanize(doc.doc_type || 'document'))}</strong>
<span class="stamp">${esc(doc.image_name || '—')}</span>
</figcaption>
</figure>`).join('')}
</div>`
: `<div class="table-msg">${t('This captain has uploaded no documents — approving now would activate an unverified account.')}</div>`}
</div>
<div class="sub-panel">
<h4 class="sub-panel-title">${t('Record')}</h4>
<div class="mini-list">
${facts.map(([k, v]) => `
<div class="kv-row">
<span>${esc(humanize(k))}</span>
<strong>${esc(/phone/i.test(k) ? maskPhone(v) : formatValue(v, k))}</strong>
</div>`).join('')}
</div>
</div>`;
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\n' + t('WARNING: no documents are on file for this captain.')
: `\n\n${docCount} ` + t('document(s) reviewed') + '.';
if (!confirm(t('Activate captain') + ` ${name}?${warning}\n\n` + t('They will be able to accept rides immediately.'))) return;
try {
await api('/Admin/driver/updateDriverFromAdmin.php', {
params: { id: driverId, status: 'active' },
});
toast(`${t('Captain')} ${name} ${t('activated.')}`, 'success');
renderDriverDocs(host, docsOffset);
} catch (err) {
if (!handleApiError(err, 'driver-approve')) toast(err.message, 'danger');
}
}
// ── Staff management ─────────────────────────────────────────────────────
async function renderStaff(host) {
host.innerHTML = `
<div class="card" id="staffPending"><div class="table-msg">${t('Loading pending accounts…')}</div></div>
<div class="card" id="staffList"><div class="table-msg">${t('Loading employees…')}</div></div>
<div class="card">
<div class="card-header"><h3 class="card-title">${t('Add a staff account')}</h3></div>
<p class="card-note">
${t('Creates a login for the Siro admin tools. Choose the password with the new member present, or have them change it at first sign-in — it is stored hashed and cannot be read back.')}
</p>
<div class="tariff-grid">
<label class="tariff-field">
<span class="tariff-label">${t('Role')}</span>
<select class="select-input" id="staffRole">
<option value="service">${t('Customer service')}</option>
<option value="admin">${t('Administrator')}</option>
</select>
</label>
<label class="tariff-field">
<span class="tariff-label">${t('Full name')} <em>${t('required')}</em></span>
<input type="text" class="form-input" id="staffName" autocomplete="off">
</label>
<label class="tariff-field">
<span class="tariff-label">${t('Phone')}</span>
<input type="text" class="form-input" id="staffPhone" autocomplete="off">
</label>
<label class="tariff-field">
<span class="tariff-label">${t('Email')}</span>
<input type="email" class="form-input" id="staffEmail" autocomplete="off">
</label>
<label class="tariff-field">
<span class="tariff-label">${t('Password')} <em>${t('required')}</em></span>
<input type="password" class="form-input" id="staffPassword" autocomplete="new-password">
</label>
<label class="tariff-field">
<span class="tariff-label">${t('Country')}</span>
<input type="text" class="form-input" id="staffCountry" value="${t('Jordan')}">
</label>
</div>
<div class="api-base-row" style="margin-top:1rem;">
<button class="btn btn-primary btn-sm" id="staffAdd"><i class="ph ph-user-plus"></i> <span>${t('Create account')}</span></button>
<span class="stamp" id="staffStatus"></span>
</div>
</div>`;
$('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]) => `<div class="table-msg is-error">${esc(humanize(name))}: ${esc(state)}</div>`)
.join('');
panel.innerHTML = `
<div class="card-header"><h3 class="card-title">${t('Pending activation')}</h3></div>
${notes}
${rows.length ? `
<div class="table-responsive">
<table class="data-table">
<thead><tr><th>${t('ID')}</th><th>${t('Name')}</th><th>${t('Phone')}</th><th>${t('Type')}</th><th>${t('Requested')}</th><th></th></tr></thead>
<tbody>
${rows.map((r) => `
<tr>
<td><strong>#${esc(r.id)}</strong></td>
<td>${esc(r.name || '—')}</td>
<td>${esc(maskPhone(r.phone))}</td>
<td><span class="badge badge-info">${esc(r.type)}</span></td>
<td>${esc(fmtDate(r.created_at, true))}</td>
<td><button class="btn btn-secondary btn-sm" data-activate="${esc(r.id)}" data-type="${esc(r.type)}">
<i class="ph ph-check"></i> ${t('Activate')}
</button></td>
</tr>`).join('')}
</tbody>
</table>
</div>` : (notes ? '' : '<div class="table-msg">' + t('No accounts are waiting for activation.') + '</div>')}`;
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 = `<div class="table-msg is-error">${esc(err.message)}</div>`;
}
}
async function loadEmployees() {
const panel = $('staffList');
try {
const payload = await api('/Admin/employee/get.php');
panel.innerHTML = `<div class="card-header"><h3 class="card-title">${t('Employees')}</h3></div><div class="panel-body"></div>`;
renderPayload(panel.querySelector('.panel-body'), payload);
} catch (err) {
if (handleApiError(err, 'employees')) return;
panel.innerHTML = `<div class="card-header"><h3 class="card-title">${t('Employees')}</h3></div><div class="table-msg is-error">${esc(err.message)}</div>`;
}
}
async function activateStaff(userId, type, host) {
if (!confirm(t('Activate') + ` ${type} ` + t('account') + ` #${userId}? ` + t('They will be able to sign in immediately.'))) return;
try {
await api('/Admin/Staff/activate.php', { params: { user_id: userId, type } });
toast(t('Account') + ` #${userId} ${t('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(t('Name and password are required.'), 'warning');
return;
}
if (password.length < 8) {
toast(t('Use a password of at least 8 characters.'), 'warning');
return;
}
if (role === 'admin' && !isSuperAdmin()) {
toast(t('Only a super admin can create administrator accounts.'), 'warning');
return;
}
const roleLabel = role === 'admin' ? t('ADMINISTRATOR') : t('customer service');
if (!confirm(
t('Create a account for') + ` ${roleLabel} \"${name}\"?\n\n` +
`${t('Phone:')} ${phone || '—'}\n${t('Email:')} ${email || '—'}\n\n` +
(role === 'admin'
? t('Administrators can see and change platform data.')
: t('Customer service staff can view operational data.'))
)) return;
busy($('staffAdd'), true, t('Creating…'));
status.textContent = '';
try {
await api('/Admin/Staff/add.php', {
params: { name, phone, email, password, role, country },
});
status.textContent = t('Created') + ` ${roleLabel} ` + t('account') + ` ${t('for')} ${name}`;
toast(t('Staff account created.'), 'success');
['staffName', 'staffPhone', 'staffEmail', 'staffPassword'].forEach((id) => { $(id).value = ''; });
loadStaffPending(host);
} catch (err) {
if (!handleApiError(err, 'staff-add')) toast(t('Could not create account:') + ' ' + err.message, 'danger');
} finally {
busy($('staffAdd'), false, t('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 = `<div class="card"><div class="table-msg">${t('Loading draft routes…')}</div></div>`;
let routes = [];
try {
const payload = await api('/Admin/transit/route/pending.php');
routes = payload?.routes || [];
} catch (err) {
if (handleApiError(err, 'routes')) return;
host.innerHTML = `<div class="card"><div class="table-msg is-error">${esc(err.message)}</div></div>`;
return;
}
if (!routes.length) {
host.innerHTML = `<div class="card"><div class="table-msg">${t('No routes are waiting for approval.')}</div></div>`;
return;
}
host.innerHTML = routes.map((route, index) => `
<div class="card" data-route-card="${index}">
<div class="card-header">
<h3 class="card-title">
${esc(route.name_ar || route.name_en || t('Unnamed route'))}
<span class="card-sub">#${esc(route.id)} · ${esc(route.org_name || t('unknown organisation'))}</span>
</h3>
<div style="display:flex; gap:0.5rem;">
<button class="btn btn-secondary btn-sm" data-route-action="reject" data-route="${index}"><i class="ph ph-x"></i> ${t('Reject')}</button>
<button class="btn btn-primary btn-sm" data-route-action="approve" data-route="${index}"><i class="ph ph-check"></i> <span>${t('Approve')}</span></button>
</div>
</div>
<div class="kpi-tiles">
<div class="kpi-tile"><div class="kpi-tile-value">${esc(route.direction || '—')}</div><div class="kpi-tile-label">${t('Direction')}</div></div>
<div class="kpi-tile"><div class="kpi-tile-value">${fmtNum(route.distance_km)} km</div><div class="kpi-tile-label">${t('Distance')}</div></div>
<div class="kpi-tile"><div class="kpi-tile-value">${fmtInt(route.duration_min)} ${t('min')}</div><div class="kpi-tile-label">${t('Duration')}</div></div>
<div class="kpi-tile"><div class="kpi-tile-value">${fmtInt(route.stops_count)}</div><div class="kpi-tile-label">${t('Stops')}</div></div>
<div class="kpi-tile"><div class="kpi-tile-value">${esc(route.country || '—')}</div><div class="kpi-tile-label">${t('Country')}</div></div>
<div class="kpi-tile"><div class="kpi-tile-value">${esc(fmtDate(route.created_at, true))}</div><div class="kpi-tile-label">${t('Submitted')}</div></div>
</div>
<div class="sub-panel">
<h4 class="sub-panel-title">${t('Stops')}</h4>
${(route.stops || []).length ? `
<ol class="stop-list">
${route.stops.map((s) => `
<li>
<span>${esc(s.name_ar || t('Unnamed stop'))}</span>
${Number(s.is_major) ? `<span class="badge badge-primary">${t('major')}</span>` : ''}
<span class="stamp">${esc(shortCoord(`${s.latitude},${s.longitude}`))}</span>
</li>`).join('')}
</ol>` : `<div class="table-msg">${t('This route has no stops recorded.')}</div>`}
</div>
</div>`).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'
? t('The route goes live and passengers can ride it.')
: t('The organisation will have to resubmit the route.');
if (!confirm(
`${verb === 'approve' ? t('Approve') : t('Reject')} ` + t('route') + ` \"${route.name_ar || route.id}\" ` +
`${t('from')} ${route.org_name || t('from 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(t('Route') + ` #${route.id} ${t(action === 'approve' ? 'approved' : 'rejected')}.`, '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 = `
<div class="card notice-card notice-danger">
<i class="ph-fill ph-warning"></i>
<span><strong>${t('This reaches every device at once and cannot be recalled.')}
${t('The message is recorded in the audit log against your account.')}</strong></span>
</div>
<div class="card">
<div class="card-header"><h3 class="card-title">${t('Compose')}</h3></div>
<div class="form-group">
<label class="form-label" for="bcAudience">${t('Audience')}</label>
<select class="select-input" id="bcAudience" style="width:100%;">
<option value="drivers">${t('All captains')}</option>
<option value="passengers">${t('All passengers')}</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="bcTitle">${t('Notification title')} <span class="stamp">${t('max 120')}</span></label>
<input type="text" class="form-input" id="bcTitle" maxlength="120" placeholder="${t('Notification title')}" style="padding-left:1rem;">
</div>
<div class="form-group">
<label class="form-label" for="bcBody">${t('Message')} <span class="stamp">${t('max 1000')}</span></label>
<textarea class="form-input decrypt-area" id="bcBody" rows="4" maxlength="1000" placeholder="${t('Message text')}"></textarea>
</div>
<div class="api-base-row">
<button class="btn btn-primary btn-sm" id="bcSend"><i class="ph ph-paper-plane-tilt"></i> <span>${t('Review & send')}</span></button>
<span class="stamp" id="bcStatus"></span>
</div>
</div>
<div class="card">
<div class="card-header"><h3 class="card-title">${t('Preview')}</h3></div>
<div class="push-preview">
<div class="push-app"><i class="ph-fill ph-car"></i> Siro</div>
<div class="push-title" id="bcPreviewTitle">${t('Notification title')}</div>
<div class="push-body" id="bcPreviewBody">${t('Message text')}</div>
</div>
</div>`;
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(t('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' ? t('every captain') : t('every passenger');
if (!title || !body) {
toast(t('Enter both a title and a message.'), 'warning');
return;
}
if (!confirm(
t('Send this notification to') + ` ${audienceLabel} ` + t('on the platform?') + '\n\n' +
`${title}\n${body}\n\n` +
t('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 = t('Sent to') + ` ${audienceLabel} ` + t('at') + ` ${new Date().toLocaleTimeString()}`;
toast(t('Notification delivered to') + ` ${audienceLabel}.`, 'success');
$('bcTitle').value = '';
$('bcBody').value = '';
$('bcPreviewTitle').textContent = t('Notification title');
$('bcPreviewBody').textContent = t('Message text');
console.info('[broadcast]', result);
} catch (err) {
if (!handleApiError(err, 'broadcast')) toast(t('Send failed:') + ' ' + err.message, 'danger');
} finally {
busy($('bcSend'), false, t('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 = `<div class="card"><div class="table-msg">${t('Loading tariff…')}</div></div>`;
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 = `<div class="card"><div class="table-msg is-error">${esc(err.message)}</div></div>`;
return;
}
if (!tariffRows.length) {
host.innerHTML = `<div class="card"><div class="table-msg">${t('No tariff rows configured.')}</div></div>`;
return;
}
const readOnly = !isSuperAdmin();
host.innerHTML = `
${readOnly ? `
<div class="card notice-card">
<i class="ph-fill ph-info"></i>
<span>${t('You are signed in as an admin, so the tariff is shown read-only. Only a super admin can change prices.')}</span>
</div>` : `
<div class="card notice-card notice-danger">
<i class="ph-fill ph-warning"></i>
<span><strong>${t('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.')}</strong></span>
</div>`}
${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 `
<div class="card" data-tariff-card="${index}">
<div class="card-header">
<h3 class="card-title">
${esc(row.country || t('Tariff'))} <span class="card-sub">${t('row #')}${esc(row.id)}</span>
</h3>
${readOnly ? '' : `
<div style="display:flex; gap:0.5rem;">
<button class="btn btn-secondary btn-sm" data-tariff-reset="${index}"><i class="ph ph-arrow-counter-clockwise"></i> ${t('Reset')}</button>
<button class="btn btn-primary btn-sm" data-tariff-save="${index}"><i class="ph ph-floppy-disk"></i> <span>${t('Review & save')}</span></button>
</div>`}
</div>
<div class="tariff-grid">
${fields.map((f) => `
<label class="tariff-field">
<span class="tariff-label">${t(esc(f.label))}${f.hint ? ` <em>${t(esc(f.hint))}</em>` : ''}</span>
<input class="form-input" type="${f.type === 'text' ? 'text' : 'number'}" step="any"
data-tariff-input="${index}" data-field="${esc(f.key)}"
value="${esc(row[f.key] ?? '')}" ${readOnly ? 'disabled' : ''}>
</label>`).join('')}
</div>
</div>`;
}
async function saveTariff(index, host) {
if (!isSuperAdmin()) {
toast(t('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(t('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(
t('Apply these pricing changes to') + ` "${row.country || t('Tariff')}" (${t('row #')}${row.id})?\n\n` +
`${summary}\n\n` +
t('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(t('Tariff updated and recorded in the audit log.'), 'success');
renderTariffEditor(host);
} catch (err) {
if (!handleApiError(err, 'tariff-save')) toast(t('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]) => `
<div class="kpi-tile">
<div class="kpi-tile-value">${esc(/status|state/i.test(k) ? labelStatus(v) : formatValue(v, k))}</div>
<div class="kpi-tile-label">${esc(humanize(k))}</div>
</div>`).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) => `<div class="kv-row"><span>${esc(String(r))}</span></div>`).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 = `
<table class="data-table">
<thead><tr>${columns.map((c) => `<th>${esc(humanize(c))}</th>`).join('')}</tr></thead>
<tbody>
${rows.slice(0, 100).map((row) => `<tr>${columns.map((c) => `<td>${cellHtml(c, row[c])}</td>`).join('')}</tr>`).join('')}
</tbody>
</table>`;
return wrap;
}
const LONG_CELL = 70;
function cellHtml(column, value) {
if (value === null || value === undefined || value === '') return '<span class="stamp">—</span>';
if (typeof value === 'object') return `<span class="stamp">${esc(JSON.stringify(value).slice(0, 60))}</span>`;
if (/status|type|state|result/i.test(column)) {
return `<span class="badge ${badgeClass(value)}">${esc(labelStatus(value))}</span>`;
}
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 `<span class="cell-long" title="${t('click to expand')}">${esc(String(text).slice(0, LONG_CELL))}…</span>
<span class="cell-full" hidden>${esc(String(text))}</span>`;
}
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, t('Running…'));
const lines = [
t('Siro Admin diagnostics —') + ` ${new Date().toISOString()}`,
`${t('Console build:')} ${BUILD}`,
`Page origin : ${location.origin}`,
`API base : ${API_BASE}`,
`Fingerprint : ${deviceFingerprint.slice(0, 20)}…`,
`Token : ${session?.jwt ? t('present (role ') + session.role + ')' : t('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}`,
` ${t('NETWORK FAILURE —')} ${err.message}`,
t('(blocked by CORS, DNS, mixed content, or the host is unreachable)'),
''
);
}
}
out.textContent = lines.join('\n');
busy(el.runDiagnosticsBtn, false, t('Run diagnostics'));
}
// Outbound message — confirmed explicitly because it reaches a real person
// and cannot be recalled.
async function sendWhatsApp() {
if (!isSuperAdmin()) {
toast(t('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(t('Enter both a recipient and a message.'), 'warning');
return;
}
if (!confirm(t('Send this WhatsApp message to') + ` ${receiver}?\n\n${message}`)) return;
status.textContent = t('Sending…');
try {
await api('/Admin/send_whatsapp_message.php', { params: { receiver, message } });
status.textContent = t('Sent to') + ` ${receiver}`;
$('waMessage').value = '';
toast(t('Message sent.'), 'success');
} catch (err) {
status.textContent = '';
toast(t('Send failed:') + ' ' + err.message, 'danger');
}
}
// Super-admin only: mirrors the Flutter EncryptToolPage (Admin/ggg.php).
async function runCryptoTool(action) {
if (!isSuperAdmin()) {
toast(t('This tool is restricted to super admins.'), 'warning');
return;
}
const text = el.decryptInput.value.trim();
const adminPhone = $('decryptPhone').value.trim();
if (!text || !adminPhone) {
toast(t('Enter both your admin phone and the value.'), 'warning');
return;
}
el.decryptOutput.textContent = t('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 = t('Failed:') + ' ' + err.message;
}
}
function collapse(text) {
return String(text).replace(/\s+/g, ' ').trim() || t('(empty response body)');
}
function setupDiagnostics() {
if (!el.apiBaseSelect) return;
el.apiBaseSelect.innerHTML = API_CANDIDATES
.map((c) => `<option value="${esc(c.value)}">${esc(c.label)}</option>`)
.join('') + `<option value="__custom__">${t('Custom…')}</option>`;
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(t('API base set to') + ` ${chosen}. ${t('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(t('Diagnostics report copied.'), 'success');
} catch {
toast(t('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 = `
<div class="modal-head">
<h3>${t('Trip')} #${esc(r.id)}</h3>
<button class="btn-icon" onclick="closeModal()"><i class="ph ph-x"></i></button>
</div>
<div class="mini-list">
${rows.map(([k, v]) => `<div class="kv-row"><span>${k}</span><strong>${esc(String(v ?? '—') || '—')}</strong></div>`).join('')}
</div>`;
$('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(t('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(t('TOTAL RIDES'), cx, cy + 18);
}
if (legendEl) {
legendEl.innerHTML = slices.map((s) => `
<span class="legend-item"><i style="background:${s.color}"></i>${s.label}
<strong>${total ? ((s.value / total) * 100).toFixed(1) : '0.0'}%</strong></span>
`).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 = t('n/a'); });
}
function tableMessage(tbody, cols, message, isError = false) {
if (!tbody) return;
tbody.innerHTML = `<tr><td colspan="${cols}" class="table-msg ${isError ? 'is-error' : ''}">${esc(message)}</td></tr>`;
}
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 `<span class="stamp">${t('unrated')}</span>`;
return `<i class="ph-fill ph-star" style="color:var(--warning)"></i> ${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 t('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()) || t('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) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 = `<i class="ph-fill ${icons[type] || icons.info}"></i><span>${esc(message)}</span>`;
document.body.appendChild(node);
setTimeout(() => {
node.classList.add('leaving');
setTimeout(() => node.remove(), 300);
}, 4200);
}
})();