نسخة كاملة من مستودع سيرو عند 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>
4178 lines
190 KiB
JavaScript
4178 lines
190 KiB
JavaScript
/* ==========================================================================
|
||
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) =>
|
||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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);
|
||
}
|
||
})();
|