/* ==========================================================================
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-8';
// ── 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.': 'اختر كابتناً بالأعلى لفحص وثائقه.',
};
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 setLanguage(next) {
lang = next;
localStorage.setItem(LANG_KEY, next);
// Nav labels, module pages and rendered tables all hold translated text,
// so the simplest correct refresh is a reload of the rendered views.
location.reload();
}
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) + '…';
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 {
throw new ApiError(`Server returned a non-JSON response (HTTP ${res.status})`, 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(`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('You have been signed out.'));
window.closeOtpModal = () => el.otpModal?.classList.remove('active');
}
function showLoginError(message, detail) {
if (!el.loginError) return;
el.loginError.hidden = false;
el.loginError.innerHTML = `
${esc(message)}
${detail ? `${esc(detail)} ` : ''}
`;
}
function clearLoginError() {
if (el.loginError) el.loginError.hidden = true;
}
async function onLogin(e) {
e.preventDefault();
const phone = el.loginEmail.value.trim();
const password = el.loginPass.value;
if (!phone || !password) return;
clearLoginError();
busy(el.loginSubmitBtn, true, t('Verifying…'));
try {
const form = new FormData();
form.append('phone', phone);
form.append('password', password);
form.append('fingerprint', deviceFingerprint);
form.append('aud', 'admin');
const payload = await api('/Admin/auth/login.php', { method: 'POST', body: form, auth: false });
if (payload?.status === 'otp_required') {
pendingPhone = phone;
el.otpPhoneText.textContent = `Enter the 3-digit code sent to ${payload.phone || 'your WhatsApp'}.`;
el.otpModal.classList.add('active');
el.otpInput.value = '';
el.otpInput.focus();
toast(payload.message || 'Verification code sent.', 'info');
} else if (payload?.jwt) {
// Trusted-device renewal path — no OTP required.
establishSession(payload);
} else {
showLoginError(
t('The server accepted the request but issued no code and no token.'),
JSON.stringify(payload).slice(0, 300)
);
}
} catch (err) {
showLoginError(err.message, err.status ? `HTTP ${err.status}` : null);
toast(err.message, 'danger');
} finally {
busy(el.loginSubmitBtn, false, t('Sign In'));
}
}
async function onVerifyOtp() {
const otp = el.otpInput.value.trim();
if (otp.length < 3) {
toast('Enter the 3-digit code.', 'warning');
return;
}
busy(el.submitOtpBtn, true, 'Verifying…');
try {
const form = new FormData();
form.append('otp', otp);
form.append('fingerprint', deviceFingerprint);
form.append('aud', 'admin');
const payload = await api('/Admin/auth/verify_login.php', { method: 'POST', body: form, auth: false });
if (!payload?.jwt) throw new ApiError('No token issued.', 0);
el.otpModal.classList.remove('active');
establishSession(payload);
} catch (err) {
showLoginError(err.message, err.status ? `HTTP ${err.status}` : null);
toast(err.message, 'danger');
} finally {
busy(el.submitOtpBtn, false, t('Verify & Sign In'));
}
}
function establishSession(payload) {
const admin = payload.admin || {};
session = {
id: admin.id ?? null,
name: admin.name || 'Admin',
email: admin.email || pendingPhone,
role: admin.role || 'admin',
jwt: payload.jwt,
issuedAt: Date.now(),
expiresIn: Number(payload.expires_in) || 3600,
};
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
showConsole();
toast(`Welcome back, ${session.name}.`, 'success');
loadEverything();
}
function readSession() {
try {
const raw = JSON.parse(localStorage.getItem(SESSION_KEY) || 'null');
if (!raw?.jwt) return null;
// Access tokens live one hour; drop anything already expired locally so
// we show the login form rather than a wall of failed requests.
const age = (Date.now() - (raw.issuedAt || 0)) / 1000;
if (age >= (raw.expiresIn || 3600)) return null;
return raw;
} catch {
return null;
}
}
function signOut(message) {
localStorage.removeItem(SESSION_KEY);
session = null;
stats = null;
loadedModules.clear();
showLogin();
if (message) toast(message, 'info');
}
function showLogin() {
el.authWrapper.classList.remove('hidden');
el.loginPass.value = '';
}
function showConsole() {
el.authWrapper.classList.add('hidden');
el.userName.textContent = session.name;
el.userRole.textContent = formatRole(session.role);
el.userAvatar.textContent = initials(session.name);
applyRoleVisibility();
}
// ── Data loading ─────────────────────────────────────────────────────────
function loadEverything() {
setConnection('loading', t('Loading live data…'));
Promise.allSettled([
loadStats(),
loadRides(),
loadDrivers(),
loadPassengers(),
loadApprovals(),
loadRidesTrend(),
]).then((results) => {
renderSessionInfo();
const ok = results.some((r) => r.status === 'fulfilled');
if (ok) {
setConnection('live', t('Live database'));
el.lastUpdated.textContent = t('Updated') + ' ' + new Date().toLocaleTimeString();
} else if (session) {
setConnection('error', t('No data from API'));
el.lastUpdated.textContent = 'No data — check diagnostics';
toast('Every endpoint failed. Open “Session & Security” → Run diagnostics.', 'danger');
}
});
}
async function loadStats() {
try {
const payload = await api('/Admin/dashbord.php');
stats = Array.isArray(payload) ? payload[0] : payload;
renderStats();
} catch (err) {
handleApiError(err, 'stats');
markKpisUnavailable();
throw err;
}
}
async function loadRides() {
const status = el.rideStatusFilter?.value || 'All';
tableMessage(el.ridesTableBody, 8, 'Loading rides…');
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, 'Loading captains…');
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, 'Loading passengers…');
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, 'Loading requests…');
try {
const payload = await api('/Admin/Staff/pending.php');
renderApprovals(payload?.data || []);
} catch (err) {
if (!handleApiError(err, 'approvals')) tableMessage(el.approvalsTableBody, 7, err.message, true);
throw err;
}
}
async function loadRidesTrend() {
try {
const rows = await api('/Admin/AdminRide/getRidesPerMonth.php');
const series = (Array.isArray(rows) ? rows : []).slice(-21).map((r) => ({
label: `${String(r.day).padStart(2, '0')}/${String(r.month).padStart(2, '0')}`,
value: Number(r.rides_count) || 0,
}));
drawLineChart('ridesTrendChart', series);
} catch (err) {
handleApiError(err, 'rides-trend');
throw err;
}
}
// ── Renderers ────────────────────────────────────────────────────────────
function renderStats() {
if (!stats) return;
const total = num(stats.countRide);
const completed = num(stats.completed_rides);
setKpi('rides', fmtInt(total));
setKpi('ridesMonth', fmtInt(stats.countRideThisMonth));
setKpi('completed', fmtInt(completed));
setKpi('completionRate', total ? `${((completed / total) * 100).toFixed(1)}%` : '—');
setKpi('drivers', fmtInt(stats.countDriver));
setKpi('driversMonth', fmtInt(stats.countDriverThisMonth));
setKpi('passengers', fmtInt(stats.countPassengers));
setKpi('passengersMonth', fmtInt(stats.countPassengersThisMonth));
// A null aggregate means "nothing recorded yet" — showing 0.00 would read
// as a measured zero.
setKpi('driverEarnings', stats.total_driver_earnings == null ? '—' : fmtMoney(stats.total_driver_earnings));
setKpi('avgFare', stats.avg_passenger_price == null ? '—' : fmtMoney(stats.avg_passenger_price));
setKpi('totalDistance', `${fmtInt(stats.total_distance)} km`);
setKpi('complaintsToday', fmtInt(stats.countComplaintToday));
setKpi('complaintsWeek', fmtInt(stats.countComplaintThisWeek));
setKpi('complaintsMonth', fmtInt(stats.countComplaintThisMonth));
drawDonut('statusDonut', [
{ label: t('Completed'), value: num(stats.completed_rides), color: '#10b981' },
{ label: t('Waiting'), value: num(stats.ongoing_rides), color: '#6366f1' },
{ label: t('Cancelled'), value: num(stats.cancelled_rides), color: '#f43f5e' },
], el.statusLegend);
drawBarChart('timeOfDayChart', [
{ label: t('Morning 6–11'), value: num(stats.morning_ride_count) },
{ label: t('Afternoon 12–17'), value: num(stats.evening_ride_count) },
{ label: t('Night 18–5'), value: num(stats.night_ride_count) },
]);
renderServiceMix();
renderTripPerformance();
}
function renderServiceMix() {
const types = [
{ label: t('Comfort'), value: num(stats.comfort), color: '#6366f1' },
{ label: t('Speed'), value: num(stats.speed), color: '#06b6d4' },
{ label: t('Lady'), value: num(stats.lady), color: '#8b5cf6' },
];
const total = types.reduce((s, t) => s + t.value, 0) || 1;
el.serviceMix.innerHTML = types.map((t) => `
${t.label}
${fmtInt(t.value)} ${((t.value / total) * 100).toFixed(1)}%
`).join('');
}
function renderTripPerformance() {
const rows = [
[t('Average trip duration'), stats.driver_avg_duration || '—'],
[t('Longest trip duration'), stats.longest_duration || '—'],
[t('Average distance'), `${fmtNum(stats.average_distance)} km`],
[t('Longest distance'), `${fmtNum(stats.longest_distance)} km`],
[t('Cancelled rides'), fmtInt(stats.cancelled_rides)],
[t('Captains with completed trips'), fmtInt(stats.num_Driver)],
];
el.tripPerformance.innerHTML = rows.map(([k, v]) => `
${k} ${v}
`).join('');
}
function renderRides() {
const rides = allRides.slice(0, ridesShown);
el.ridesMeta.textContent = allRides.length
? `Showing ${rides.length} of ${allRides.length} trip${allRides.length === 1 ? '' : 's'}`
: '—';
el.ridesMore.hidden = ridesShown >= allRides.length;
if (!rides.length) {
tableMessage(el.ridesTableBody, 8, 'No rides match this filter.');
return;
}
el.ridesTableBody.innerHTML = rides.map((r) => `
#${esc(r.id)}
${esc(r.passenger_full_name || 'Unknown')}
${esc(r.driver_full_name || 'Unassigned')}
${routeCell(r)}
${fmtMoney(rideFare(r))}
${esc(labelStatus(r.status))}
${esc(rideTimestamp(r))}
`).join('');
el.ridesTableBody.querySelectorAll('[data-ride]').forEach((btn) => {
btn.addEventListener('click', () => {
const ride = allRides.find((r) => String(r.id) === btn.dataset.ride);
if (ride) showRideDetails(ride);
});
});
}
// The ride table carries two generations of columns. Older rows expose
// address_start/address_end, price_for_passenger and created_at; rows written
// by the current ride pipeline expose start_location/end_location as
// "lat,lng" pairs, a plain `price`, and separate date + time columns.
function rideFare(r) {
return r.price_for_passenger ?? r.price ?? 0;
}
function rideTimestamp(r) {
if (r.created_at) return fmtDate(r.created_at);
if (r.date) return fmtDate(`${r.date} ${r.time && r.time !== '00:00:00' ? r.time : ''}`.trim());
return '—';
}
function routeCell(r) {
if (r.address_start || r.address_end) {
return ` ${esc(shorten(r.address_start))} ${esc(shorten(r.address_end))}`;
}
if (r.start_location) {
const link = mapLink(r.start_location);
return `
${esc(shortCoord(r.start_location))} ${esc(shortCoord(r.end_location))}
`;
}
return '— ';
}
function shortCoord(value) {
if (!value) return '—';
return String(value).split(',').map((n) => Number(n).toFixed(4)).join(', ');
}
function mapLink(coords) {
return `https://www.openstreetmap.org/?mlat=${encodeURIComponent(String(coords).split(',')[0])}&mlon=${encodeURIComponent(String(coords).split(',')[1] || '')}#map=15/`;
}
// ── Role model ───────────────────────────────────────────────────────────
// Mirrors the Flutter admin app: a plain `admin` observes, a `super_admin`
// edits, approves and sees unmasked contact details.
const isSuperAdmin = () => session?.role === 'super_admin';
function maskPhone(phone) {
if (!phone || phone === '—') return '—';
if (isSuperAdmin()) return String(phone);
const s = String(phone);
return s.length > 6 ? `${s.slice(0, 4)}****${s.slice(-2)}` : '****';
}
function applyRoleVisibility() {
document.querySelectorAll('[data-requires-super]').forEach((node) => {
node.hidden = !isSuperAdmin();
});
}
// ── Lookup by phone / id ─────────────────────────────────────────────────
async function lookupRidesByPhone(phone) {
tableMessage(el.ridesTableBody, 8, `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, `Searching captains for ${term}…`);
const params = /^\d+$/.test(term) && term.length < 8
? { driver_id: term }
: (term.includes('@') ? { driverEmail: term } : { driverPhone: term });
try {
const payload = await api('/Admin/AdminCaptain/getCaptainDetailsByEmailOrIDOrPhone.php', { params });
const rows = normaliseRows(payload);
renderDrivers(rows, rows.length);
el.driversMeta.textContent = `Search results for “${term}”`;
if (!rows.length) tableMessage(el.driversTableBody, 8, `No captain matches “${term}”.`);
} catch (err) {
if (!handleApiError(err, 'captain-lookup')) tableMessage(el.driversTableBody, 8, err.message, true);
}
}
async function lookupPassenger(term) {
tableMessage(el.passengersTableBody, 8, `Searching passengers for ${term}…`);
const params = /^\d+$/.test(term) && term.length < 8
? { passengerId: term }
: (term.includes('@') ? { passengerEmail: term } : { passengerphone: term });
try {
const payload = await api('/Admin/getPassengerbyEmail.php', { params });
const rows = normaliseRows(payload);
renderPassengers(rows);
if (!rows.length) tableMessage(el.passengersTableBody, 8, `No passenger matches “${term}”.`);
} catch (err) {
if (!handleApiError(err, 'passenger-lookup')) tableMessage(el.passengersTableBody, 8, err.message, true);
}
}
function normaliseRows(payload) {
if (Array.isArray(payload)) return payload;
if (payload && typeof payload === 'object') {
if (Array.isArray(payload.data)) return payload.data;
return [payload];
}
return [];
}
// ── Profile drawers ──────────────────────────────────────────────────────
async function openCaptainProfile(id) {
openProfile(`Captain #${id}`, () =>
api('/Admin/AdminCaptain/getCaptainDetailsById.php', { params: { driver_id: id } }));
}
async function openPassengerProfile(id) {
openProfile(`Passenger #${id}`, () =>
api('/Admin/getPassengerDetailsByPassengerID.php', { params: { passengerID: id } }));
}
async function openProfile(title, fetcher) {
const body = $('modalBodyContent');
body.innerHTML = `
${esc(title)}
Loading profile…
`;
$('detailsModal').classList.add('active');
try {
const payload = await fetcher();
const record = normaliseRows(payload)[0];
if (!record) throw new ApiError('No profile returned for this record.', 0);
const entries = Object.entries(record)
.filter(([k]) => !/token|password|fingerprint/i.test(k))
.map(([k, v]) => [humanize(k), /phone/i.test(k) ? maskPhone(v) : formatValue(v, k)]);
body.innerHTML = `
${esc(title)}
${entries.map(([k, v]) => `
${esc(k)} ${esc(String(v))}
`).join('')}
`;
} catch (err) {
if (handleApiError(err, 'profile')) return;
body.innerHTML = `
${esc(title)}
${esc(err.message)}
`;
}
}
function renderDrivers(drivers, total) {
el.driversMeta.textContent = `Page ${driversPage} of ${driversPages} · ${fmtInt(total)} captains`;
if (!drivers.length) {
tableMessage(el.driversTableBody, 8, 'No captains found.');
return;
}
el.driversTableBody.innerHTML = drivers.map((d) => `
#${esc(d.id)}
${esc(`${d.first_name || ''} ${d.last_name || ''}`.trim() || 'Unnamed')}
${esc(maskPhone(d.phone))}
${esc(d.email || '—')}
${rating(d.passengerAverageRating)}
${fmtInt(d.countPassengerRide)}
${fmtInt(d.countPassengerCancel)}
${esc(d.status || 'unknown')}
`).join('');
el.driversTableBody.querySelectorAll('[data-captain]').forEach((row) =>
row.addEventListener('click', () => openCaptainProfile(row.dataset.captain)));
}
function renderPassengers(rows) {
if (!rows.length) {
tableMessage(el.passengersTableBody, 8, 'No passengers found.');
return;
}
el.passengersTableBody.innerHTML = rows.map((p) => `
#${esc(p.id)}
${esc(`${p.first_name || ''} ${p.last_name || ''}`.trim() || 'Unnamed')}
${esc(p.email || maskPhone(p.phone))}
${fmtInt(p.countPassengerRide)}
${rating(p.passengerAverageRating)}
${fmtInt(p.countPassengerCancel)}
${esc(fmtDate(p.created_at, true))}
${esc(p.status || 'unknown')}
`).join('');
el.passengersTableBody.querySelectorAll('[data-passenger]').forEach((row) =>
row.addEventListener('click', () => openPassengerProfile(row.dataset.passenger)));
}
function renderApprovals(pending) {
el.approvalsCount.hidden = pending.length === 0;
el.approvalsCount.textContent = pending.length;
if (!pending.length) {
tableMessage(el.approvalsTableBody, 7, 'No pending requests.');
return;
}
const isSuper = session?.role === 'super_admin';
el.approvalsTableBody.innerHTML = pending.map((p) => `
#${esc(p.id)}
${esc(p.name || '—')}
${esc(p.phone || '—')}
${esc(p.type)}
${esc(p.role || '—')}
${esc(fmtDate(p.created_at, true))}
${(isSuper && p.type === 'admin') ? `
Approve
` : 'super admin only '}
`).join('');
el.approvalsTableBody.querySelectorAll('[data-approve]').forEach((b) =>
b.addEventListener('click', () => decideApproval(b.dataset.approve, 'approved')));
el.approvalsTableBody.querySelectorAll('[data-reject]').forEach((b) =>
b.addEventListener('click', () => decideApproval(b.dataset.reject, 'rejected')));
}
async function decideApproval(adminId, action) {
if (!confirm(`Set admin #${adminId} to "${action}"?`)) return;
try {
const form = new FormData();
form.append('admin_id', adminId);
form.append('action', action);
await api('/Admin/auth/approve_admin.php', { method: 'POST', body: form });
toast(`Admin #${adminId} ${action}.`, 'success');
loadApprovals();
} catch (err) {
if (!handleApiError(err, 'approve')) toast(err.message, 'danger');
}
}
function renderSessionInfo() {
if (!session) return;
const expiresAt = new Date(session.issuedAt + session.expiresIn * 1000);
const rows = [
['Signed in as', session.name],
['Admin ID', session.id ?? '—'],
['Role', formatRole(session.role)],
['Token expires', expiresAt.toLocaleString()],
['Device fingerprint', deviceFingerprint.slice(0, 24) + '…'],
['API endpoint', location.origin + API_BASE],
['Console build', BUILD],
];
el.sessionInfo.innerHTML = rows.map(([k, v]) =>
`${k} ${esc(String(v))}
`).join('');
}
// ── Extended modules (parity with the Flutter admin app) ────────────────
// Each entry becomes a sidebar item plus a lazily-loaded page. Panels are
// rendered by shape, not by hand-written field lists, so an endpoint that
// grows a column shows it without a code change here.
const MODULES = [
{
id: 'liveOps', group: 'Realtime & Analytics', icon: 'ph-broadcast', title: 'Live Operations',
subtitle: 'Realtime fleet counters and the alerts that need attention now',
panels: [
{ title: 'Realtime counters', path: '/Admin/v2/realtime_dashboard.php' },
{ title: 'Smart alerts', path: '/Admin/v2/smart_alerts.php' },
],
},
{
id: 'growth', group: 'Realtime & Analytics', icon: 'ph-trend-up', title: 'Growth',
subtitle: 'How many passengers and captains joined each day over the last 30 days',
custom: renderGrowth,
},
{
id: 'analyticsV2', group: 'Realtime & Analytics', icon: 'ph-chart-line', title: 'Advanced Analytics',
subtitle: 'Revenue, ranking and dashboard aggregates from the v2 engine',
custom: renderAnalytics,
},
{
id: 'financeV2', group: 'Finance', icon: 'ph-bank', title: 'Financial V2',
subtitle: 'Settlement runs and financial aggregates',
panels: [
{ title: 'Financial stats', path: '/Admin/v2/financial/stats.php' },
{ title: 'Settlements', path: '/Admin/v2/financial/settlements.php' },
],
},
{
id: 'marketing', group: 'Growth & Pricing', icon: 'ph-megaphone', title: 'Marketing Intelligence',
subtitle: 'Market share, competitor price gaps, anomalies and campaign history',
panels: [
{ title: 'Market share', path: '/Admin/marketing/get_market_share_analytics.php' },
{ title: 'Price comparison', path: '/Admin/marketing/get_price_comparison.php' },
{ title: 'Market anomalies', path: '/Admin/marketing/get_market_anomalies.php' },
{ title: 'Surge opportunity index', path: '/Admin/marketing/surge_opportunity_index.php' },
{ title: 'Win-back hotspots', path: '/Admin/marketing/winback_hotspot_targets.php' },
{ title: 'Campaign log', path: '/Admin/marketing/get_campaigns_log.php' },
],
},
{
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' },
{ title: 'AI price prediction', path: '/Admin/marketing/ai_price_prediction.php' },
{ title: 'Price gap heatmap', path: '/Admin/marketing/get_price_gap_heatmap.php' },
{ title: 'Telemetry', path: '/Admin/marketing/get_telemetry.php' },
],
},
{
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' }],
},
{
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: 'Registered transit organisations',
panels: [{ title: 'Organisations', path: '/Admin/transit/org/list.php' }],
},
{
id: 'routes', group: 'Transit', icon: 'ph-path', title: 'Route Approvals',
subtitle: 'Draft routes submitted by organisations, awaiting a decision',
custom: renderRouteApprovals,
},
{
id: 'campaigns', superOnly: true, group: 'Growth & Pricing', icon: 'ph-rocket-launch',
title: 'Campaign Launcher',
subtitle: 'Generate an AI pricing campaign, preview it, then dispatch',
custom: renderCampaigns,
},
{
id: 'appVersion', superOnly: true, group: 'Administration', icon: 'ph-device-mobile',
title: 'App Versions',
subtitle: 'The version each Siro app checks itself against',
custom: renderAppVersions,
},
{
id: 'broadcast', superOnly: true, group: 'Administration', icon: 'ph-megaphone-simple',
title: 'Broadcast Notification',
subtitle: 'Push a notification to every captain or every passenger',
custom: renderBroadcast,
},
{
id: 'driverDocs', group: 'Quality', icon: 'ph-identification-card', title: 'Driver Documents',
subtitle: 'Captains awaiting document review and activation',
custom: renderDriverDocs,
},
{
id: 'staff', superOnly: true, group: 'Administration', icon: 'ph-identification-badge', title: 'Staff & Employees',
subtitle: 'Internal staff records, activation and onboarding',
custom: renderStaff,
},
{
id: 'audit', group: 'Administration', icon: 'ph-scroll', title: 'Audit Log',
subtitle: 'Privileged actions recorded across the platform',
panels: [{ title: 'Audit entries', path: '/Admin/v2/security/audit_logs.php' }],
},
{
id: 'errors', group: 'Administration', icon: 'ph-bug', title: 'Error Log',
subtitle: 'Last errors reported by the mobile apps',
panels: [{ title: 'Recent errors', path: '/Admin/error/error_list_last20.php' }],
},
];
const loadedModules = new Set();
function buildModules() {
const menu = document.querySelector('.sidebar-menu');
const main = document.querySelector('.content-body');
if (!menu || !main) return;
const groups = [...new Set(MODULES.map((m) => m.group))];
groups.forEach((group) => {
const label = document.createElement('div');
label.className = 'menu-label';
label.textContent = t(group);
menu.appendChild(label);
MODULES.filter((m) => m.group === group).forEach((mod) => {
const item = document.createElement('a');
item.className = 'nav-item';
item.dataset.view = `mod_${mod.id}`;
item.dataset.module = mod.id;
if (mod.superOnly) {
item.setAttribute('data-requires-super', '');
item.hidden = true;
}
item.innerHTML = `${esc(t(mod.title))} `;
menu.appendChild(item);
const section = document.createElement('section');
section.className = 'page-view';
section.id = `mod_${mod.id}`;
section.innerHTML = `
`;
main.appendChild(section);
section.querySelector('[data-reload]').addEventListener('click', () => loadModule(mod, true));
});
});
}
async function loadModule(mod, force = false) {
if (loadedModules.has(mod.id) && !force) return;
loadedModules.add(mod.id);
const host = $(`panels_${mod.id}`);
if (mod.custom) {
await mod.custom(host);
return;
}
host.innerHTML = mod.panels.map((p) => `
`).join('');
await Promise.all(mod.panels.map(async (p) => {
const body = host.querySelector(`[data-panel="${cssEscape(p.path)}"] .panel-body`);
try {
const payload = await api(p.path, { params: p.params || null });
renderPayload(body, payload);
} catch (err) {
if (handleApiError(err, mod.id)) return;
body.innerHTML = `${esc(err.message)}
`;
}
}));
}
// Endpoints that answer only for a specific record get an input rather than
// an empty panel: they require a parameter, so eagerly calling them would
// just render an error.
function renderLookupTools(host, tools) {
host.innerHTML = tools.map((tool, i) => `
`).join('');
host.querySelectorAll('[data-lookup-run]').forEach((btn) => {
const index = Number(btn.dataset.lookupRun);
const tool = tools[index];
const input = host.querySelector(`[data-lookup-input="${index}"]`);
const body = host.querySelector(`[data-lookup-body="${index}"]`);
const run = async () => {
const value = input.value.trim();
if (!value) return;
body.innerHTML = 'Querying…
';
try {
const payload = await api(tool.path, { params: { [tool.field]: value } });
renderPayload(body, payload);
} catch (err) {
if (handleApiError(err, 'lookup')) return;
body.innerHTML = `${esc(err.message)}
`;
}
};
btn.addEventListener('click', run);
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') run(); });
});
}
// ── Growth ───────────────────────────────────────────────────────────────
// growth.php returns one row per day that had signups — days with none are
// simply absent. Plotting those rows directly would compress gaps and make
// a quiet week look like steady growth, so the series is expanded to a
// continuous 30-day axis with explicit zeros.
function densifyDays(rows, valueKey, days = 30) {
const byDate = new Map();
(rows || []).forEach((r) => byDate.set(String(r.date).slice(0, 10), Number(r[valueKey]) || 0));
const series = [];
const today = new Date();
for (let i = days - 1; i >= 0; i--) {
const d = new Date(today);
d.setDate(today.getDate() - i);
const iso = d.toISOString().slice(0, 10);
series.push({
label: `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}`,
value: byDate.get(iso) || 0,
});
}
return series;
}
const sumSeries = (series) => series.reduce((a, p) => a + p.value, 0);
async function renderGrowth(host) {
host.innerHTML = '';
let payload;
try {
payload = await api('/Admin/v2/analytics/growth.php');
} catch (err) {
if (handleApiError(err, 'growth')) return;
host.innerHTML = ``;
return;
}
const passengers = densifyDays(payload?.passenger_daily, 'new_passengers');
const drivers = densifyDays(payload?.driver_daily, 'new_drivers');
const totals = payload?.totals || {};
const joinedP = sumSeries(passengers);
const joinedD = sumSeries(drivers);
const activeDays = passengers.filter((p, i) => p.value || drivers[i].value).length;
host.innerHTML = `
${growthTile(fmtInt(totals.passengers), 'Passengers in total', 'ph-users', 'warning')}
${growthTile(fmtInt(totals.drivers), 'Captains in total', 'ph-steering-wheel', 'info')}
${growthTile(`+${fmtInt(joinedP)}`, 'Passengers joined · 30 days', 'ph-user-plus', 'primary')}
${growthTile(`+${fmtInt(joinedD)}`, 'Captains joined · 30 days', 'ph-user-plus', 'success')}
${activeDays === 0
? '
Nobody signed up in the last 30 days.
'
: `
${activeDays} of 30 days had at least one signup
`}
`;
drawMultiLine('growthChart', [
{ label: 'Passengers', color: '#6366f1', points: passengers },
{ label: 'Captains', color: '#10b981', points: drivers },
]);
}
function growthTile(value, label, icon, tone) {
return `
`;
}
// ── Advanced analytics ───────────────────────────────────────────────────
async function renderAnalytics(host) {
host.innerHTML = `
Revenue is counted from completed rides only. Total is what passengers paid;
platform share is what remains after the captain's cut.
`;
// Revenue
try {
const rev = await api('/Admin/v2/analytics/revenue.php');
const daily = rev?.daily || [];
const summary = rev?.summary || {};
const series = densifyDays(daily, 'total_revenue');
const rides = densifyDays(daily, 'total_rides');
$('anRevenue').innerHTML = `
${summary.total_revenue_all == null ? '—' : fmtMoney(summary.total_revenue_all)}
Total collected
${summary.total_profit_all == null ? '—' : fmtMoney(summary.total_profit_all)}
Platform share
${summary.avg_ride_price == null ? '—' : fmtMoney(summary.avg_ride_price)}
Average fare
${fmtInt(sumSeries(rides))}
Completed rides
${sumSeries(series) === 0 ? 'No completed rides carried a fare in this period.
' : ''}`;
drawMultiLine('revenueTrend', [{ label: 'Revenue', color: '#06b6d4', points: series }]);
} catch (err) {
if (handleApiError(err, 'analytics-revenue')) return;
$('anRevenue').innerHTML = `${esc(err.message)}
`;
}
// Captain ranking
try {
const ranking = await api('/Admin/v2/analytics/driver_ranking.php');
const body = $('anRanking');
body.innerHTML = '
';
renderPayload(body.querySelector('.panel-body'), ranking);
} catch (err) {
if (handleApiError(err, 'analytics-ranking')) return;
$('anRanking').innerHTML = `${esc(err.message)}
`;
}
}
// ── 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 = `
Removal is permanent. 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.
Removes the phone number from the blacklist so it can register again. It does
not restore a deleted account.
Account type
Passenger
Captain
Phone
Lift block
`;
$('rmSubmit').addEventListener('click', () => removeAndBlacklist(host));
$('ubSubmit').addEventListener('click', () => liftBlock(host));
loadBlacklist();
}
async function loadBlacklist() {
const panel = $('blacklistCurrent');
try {
const payload = await api('/Admin/v2/quality/blacklist_manager.php', {
params: { action_type: 'get_all' },
});
panel.innerHTML = '
';
renderPayload(panel.querySelector('.panel-body'), payload);
} catch (err) {
if (handleApiError(err, 'blacklist')) return;
panel.innerHTML = `${esc(err.message)}
`;
}
}
async function removeAndBlacklist(host) {
const type = $('rmType').value;
const phone = $('rmPhone').value.trim();
const id = $('rmId').value.trim();
const reason = $('rmReason').value.trim();
const confirmPhone = $('rmConfirmPhone').value.trim();
const status = $('rmStatus');
const label = type === 'driver' ? 'captain' : 'passenger';
if (!phone) {
toast('Enter the phone number of the account to remove.', 'warning');
return;
}
if (confirmPhone !== phone) {
toast('The retyped phone does not match — nothing was deleted.', 'danger');
return;
}
if (type === 'driver' && !id) {
toast('Removing a captain requires their account ID.', 'warning');
return;
}
if (!confirm(
`Permanently delete the ${label} on ${phone} and blacklist that number?\n\n` +
`${reason ? `Reason: ${reason}\n\n` : ''}` +
'The account is deleted from the database. This cannot be undone.'
)) return;
busy($('rmSubmit'), true, 'Deleting…');
status.textContent = '';
try {
if (type === 'driver') {
await api('/Admin/driver/deleteCaptain.php', { params: { driver_id: id, phone, reason } });
} else {
await api('/Admin/passenger/admin_delete_and_blacklist_passenger.php', {
params: { id, phone, reason },
});
}
status.textContent = `Removed and blacklisted ${phone}`;
toast(`${label === 'captain' ? 'Captain' : 'Passenger'} removed and blacklisted.`, 'success');
['rmPhone', 'rmId', 'rmReason', 'rmConfirmPhone'].forEach((f) => { $(f).value = ''; });
loadBlacklist();
} catch (err) {
if (!handleApiError(err, 'remove-account')) toast(err.message, 'danger');
} finally {
busy($('rmSubmit'), false, 'Delete and blacklist');
}
}
async function liftBlock() {
const type = $('ubType').value;
const phone = $('ubPhone').value.trim();
const status = $('ubStatus');
if (!phone) {
toast('Enter the blocked phone number.', 'warning');
return;
}
if (!confirm(`Lift the block on ${phone}? They will be able to register again.`)) return;
busy($('ubSubmit'), true, 'Working…');
try {
const path = type === 'driver'
? '/Admin/driver/remove_from_blacklist.php'
: '/Admin/passenger/admin_unblacklist.php';
await api(path, { params: { phone } });
status.textContent = `Block lifted for ${phone}`;
toast('Block lifted.', 'success');
$('ubPhone').value = '';
loadBlacklist();
} catch (err) {
if (!handleApiError(err, 'lift-block')) toast(err.message, 'danger');
} finally {
busy($('ubSubmit'), false, 'Lift block');
}
}
// ── Campaign launcher ────────────────────────────────────────────────────
// trigger_campaign.php asks Gemini for a campaign, writes a promo code valid
// for seven days, and pushes it to every passenger in the country. The
// preview (dry_run=1) runs the same analysis and stops before both.
function renderCampaigns(host) {
host.innerHTML = `
Launching creates a real discount code and notifies every passenger in the selected country.
The promo stays valid for seven days. Always preview first.
Loading campaign history…
`;
$('cmpPreview').addEventListener('click', () => runCampaign(true));
$('cmpLaunch').addEventListener('click', () => runCampaign(false));
loadCampaignLog();
}
// A launch is only allowed for parameters that were previewed, so an edit
// after previewing disarms the button again.
let previewedCampaign = null;
function campaignParams() {
return {
country_code: $('cmpCountry').value,
region_name: $('cmpRegion').value.trim(),
siro_base_price: $('cmpBasePrice').value.trim(),
};
}
async function runCampaign(isPreview) {
const params = campaignParams();
const signature = JSON.stringify(params);
if (!isPreview) {
if (signature !== previewedCampaign) {
toast('Parameters changed since the preview — preview again before launching.', 'warning');
$('cmpLaunch').disabled = true;
return;
}
if (!confirm(
`Launch this campaign in ${params.country_code}?\n\n` +
'It creates a discount code valid for 7 days and pushes a notification to every passenger there.\n\n' +
'This cannot be undone.'
)) return;
}
const btn = isPreview ? $('cmpPreview') : $('cmpLaunch');
busy(btn, true, isPreview ? 'Analysing…' : 'Launching…');
$('cmpResult').innerHTML = 'Running market analysis…
';
try {
const payload = await api('/Admin/marketing/trigger_campaign.php', {
params: isPreview ? { ...params, dry_run: '1' } : params,
});
$('cmpResult').innerHTML = `
`;
renderPayload($('cmpResult').querySelector('.panel-body'), payload);
if (isPreview) {
const opportunity = payload?.ai_analysis?.opportunity_detected ?? payload?.campaign_created;
previewedCampaign = signature;
$('cmpLaunch').disabled = false;
$('cmpStatus').textContent = opportunity === false
? 'The AI found no opportunity — launching would still send.'
: `Previewed ${params.country_code}. Launch is now enabled.`;
} else {
previewedCampaign = null;
$('cmpLaunch').disabled = true;
$('cmpStatus').textContent = `Launched at ${new Date().toLocaleTimeString()}`;
toast('Campaign dispatched.', 'success');
loadCampaignLog();
}
} catch (err) {
if (!handleApiError(err, 'campaign')) {
$('cmpResult').innerHTML = `${esc(err.message)}
`;
toast(err.message, 'danger');
}
} finally {
busy(btn, false, isPreview ? 'Preview' : 'Launch campaign');
// busy() clears `disabled`, so re-apply the arming rule afterwards:
// launching stays locked until the current parameters are previewed.
$('cmpLaunch').disabled = previewedCampaign !== JSON.stringify(campaignParams());
}
}
async function loadCampaignLog() {
const panel = $('cmpLog');
try {
const payload = await api('/Admin/marketing/get_campaigns_log.php');
panel.innerHTML = '
';
renderPayload(panel.querySelector('.panel-body'), payload);
} catch (err) {
if (handleApiError(err, 'campaign-log')) return;
panel.innerHTML = `${esc(err.message)}
`;
}
}
// ── App versions ─────────────────────────────────────────────────────────
async function renderAppVersions(host) {
host.innerHTML = 'Loading package versions…
';
let packages = [];
try {
packages = normaliseRows(await api('/serviceapp/getPackages.php'));
} catch (err) {
if (handleApiError(err, 'packages')) return;
host.innerHTML = ``;
return;
}
if (!packages.length) {
host.innerHTML = 'No package rows configured.
';
return;
}
host.innerHTML = `
Each app compares its own build against this number on launch. Raising it can force every
user of that app to update before they can continue.
${packages.map((pkg, index) => `
`).join('')}`;
host.querySelectorAll('[data-pkg-save]').forEach((btn) =>
btn.addEventListener('click', () => saveVersion(packages[Number(btn.dataset.pkgSave)], Number(btn.dataset.pkgSave), host)));
}
async function saveVersion(pkg, index, host) {
const input = host.querySelector(`[data-pkg-version="${index}"]`);
const version = input.value.trim();
if (version === String(pkg.version ?? '')) {
toast('Version unchanged.', 'info');
return;
}
// Mirrors the server-side check so a typo is caught before the request.
if (!/^\d+(\.\d+){0,3}$/.test(version)) {
toast('Use digits separated by dots, e.g. 1.4.2', 'warning');
return;
}
if (!confirm(
`Set ${pkg.name || `package #${pkg.id}`} to version ${version} (was ${pkg.version ?? '—'})?\n\n` +
'Users on an older build may be prompted or forced to update.'
)) return;
try {
await api('/serviceapp/updatePackages.php', { params: { id: pkg.id, version } });
toast(`Version set to ${version}.`, 'success');
renderAppVersions(host);
} catch (err) {
if (!handleApiError(err, 'package-save')) toast(err.message, 'danger');
}
}
// ── Driver document review ───────────────────────────────────────────────
// The list is paged server-side (limit/offset). Activation posts
// status=active to Admin/driver/updateDriverFromAdmin.php, exactly as the
// Flutter DriverDocsController does.
const DOCS_PAGE_SIZE = 15;
let docsOffset = 0;
async function renderDriverDocs(host, offset = 0) {
docsOffset = offset;
host.innerHTML = 'Loading captains awaiting review…
';
let drivers = [];
try {
const payload = await api('/auth/driver/drivers_pending_list.php', {
params: { limit: DOCS_PAGE_SIZE, offset },
});
drivers = normaliseRows(payload);
} catch (err) {
if (handleApiError(err, 'driver-docs')) return;
host.innerHTML = ``;
return;
}
if (!drivers.length && offset === 0) {
host.innerHTML = 'No captains are awaiting document review.
';
return;
}
host.innerHTML = `
ID Name Phone
${drivers.map((d) => `
#${esc(d.id)}
${esc(`${d.first_name || ''} ${d.last_name || ''}`.trim() || 'Unnamed')}
${esc(maskPhone(d.phone))}
Review documents
`).join('')}
Pick a captain above to inspect their documents.
`;
$('docsPrev')?.addEventListener('click', () =>
renderDriverDocs(host, Math.max(0, offset - DOCS_PAGE_SIZE)));
$('docsNext')?.addEventListener('click', () =>
renderDriverDocs(host, offset + DOCS_PAGE_SIZE));
host.querySelectorAll('[data-review]').forEach((btn) =>
btn.addEventListener('click', () => showDriverDocs(btn.dataset.review, host)));
}
async function showDriverDocs(driverId, host) {
const panel = $('docsDetail');
panel.innerHTML = 'Loading documents…
';
let driver = {};
let documents = [];
try {
// Sent as a POST body: filterRequest() ignores query strings, so the
// mobile app's GET "?id=" form never reaches this endpoint's $driverId.
const payload = await api('/auth/driver/driver_details.php', { params: { id: driverId } });
driver = payload?.driver || {};
documents = payload?.documents || [];
} catch (err) {
if (handleApiError(err, 'driver-details')) return;
panel.innerHTML = `${esc(err.message)}
`;
return;
}
const facts = Object.entries(driver)
.filter(([k, v]) => !/token|password|fingerprint/i.test(k) && v !== null && v !== '')
.slice(0, 18);
panel.innerHTML = `
Documents (${documents.length})
${documents.length ? `
${documents.map((doc) => `
${doc.link
? `
`
: ' no file linked
'}
${esc(humanize(doc.doc_type || 'document'))}
${esc(doc.image_name || '—')}
`).join('')}
`
: '
This captain has uploaded no documents — approving now would activate an unverified account.
'}
Record
${facts.map(([k, v]) => `
${esc(humanize(k))}
${esc(/phone/i.test(k) ? maskPhone(v) : formatValue(v, k))}
`).join('')}
`;
panel.querySelector('[data-approve-driver]').addEventListener('click', () =>
approveDriver(driverId, driver, documents.length, host));
}
async function approveDriver(driverId, driver, docCount, host) {
const name = `${driver.first_name || ''} ${driver.last_name || ''}`.trim() || `#${driverId}`;
const warning = docCount === 0
? '\n\nWARNING: no documents are on file for this captain.'
: `\n\n${docCount} document(s) reviewed.`;
if (!confirm(`Activate captain ${name}?${warning}\n\nThey will be able to accept rides immediately.`)) return;
try {
await api('/Admin/driver/updateDriverFromAdmin.php', {
params: { id: driverId, status: 'active' },
});
toast(`Captain ${name} activated.`, 'success');
renderDriverDocs(host, docsOffset);
} catch (err) {
if (!handleApiError(err, 'driver-approve')) toast(err.message, 'danger');
}
}
// ── Staff management ─────────────────────────────────────────────────────
async function renderStaff(host) {
host.innerHTML = `
Loading pending accounts…
`;
$('staffAdd').addEventListener('click', () => addStaff(host));
loadStaffPending(host);
loadEmployees();
}
async function loadStaffPending(host) {
const panel = $('staffPending');
try {
const payload = await api('/Admin/Staff/pending.php');
const rows = payload?.data || [];
const sources = payload?.sources || {};
const notes = Object.entries(sources)
.filter(([, state]) => state !== 'ok')
.map(([name, state]) => `${esc(humanize(name))}: ${esc(state)}
`)
.join('');
panel.innerHTML = `
${notes}
${rows.length ? `
ID Name Phone Type Requested
${rows.map((r) => `
#${esc(r.id)}
${esc(r.name || '—')}
${esc(maskPhone(r.phone))}
${esc(r.type)}
${esc(fmtDate(r.created_at, true))}
Activate
`).join('')}
` : (notes ? '' : 'No accounts are waiting for activation.
')}`;
panel.querySelectorAll('[data-activate]').forEach((btn) =>
btn.addEventListener('click', () => activateStaff(btn.dataset.activate, btn.dataset.type, host)));
} catch (err) {
if (handleApiError(err, 'staff-pending')) return;
panel.innerHTML = `${esc(err.message)}
`;
}
}
async function loadEmployees() {
const panel = $('staffList');
try {
const payload = await api('/Admin/employee/get.php');
panel.innerHTML = '
';
renderPayload(panel.querySelector('.panel-body'), payload);
} catch (err) {
if (handleApiError(err, 'employees')) return;
panel.innerHTML = `${esc(err.message)}
`;
}
}
async function activateStaff(userId, type, host) {
if (!confirm(`Activate ${type} account #${userId}? They will be able to sign in immediately.`)) return;
try {
await api('/Admin/Staff/activate.php', { params: { user_id: userId, type } });
toast(`Account #${userId} activated.`, 'success');
loadStaffPending(host);
} catch (err) {
if (!handleApiError(err, 'staff-activate')) toast(err.message, 'danger');
}
}
async function addStaff(host) {
const role = $('staffRole').value;
const name = $('staffName').value.trim();
const phone = $('staffPhone').value.trim();
const email = $('staffEmail').value.trim();
const password = $('staffPassword').value;
const country = $('staffCountry').value.trim() || 'Jordan';
const status = $('staffStatus');
if (!name || !password) {
toast('Name and password are required.', 'warning');
return;
}
if (password.length < 8) {
toast('Use a password of at least 8 characters.', 'warning');
return;
}
if (role === 'admin' && !isSuperAdmin()) {
toast('Only a super admin can create administrator accounts.', 'warning');
return;
}
const roleLabel = role === 'admin' ? 'ADMINISTRATOR' : 'customer service';
if (!confirm(
`Create a ${roleLabel} account for "${name}"?\n\n` +
`Phone: ${phone || '—'}\nEmail: ${email || '—'}\n\n` +
(role === 'admin'
? 'Administrators can see and change platform data.'
: 'Customer service staff can view operational data.')
)) return;
busy($('staffAdd'), true, 'Creating…');
status.textContent = '';
try {
await api('/Admin/Staff/add.php', {
params: { name, phone, email, password, role, country },
});
status.textContent = `Created ${roleLabel} account for ${name}`;
toast('Staff account created.', 'success');
['staffName', 'staffPhone', 'staffEmail', 'staffPassword'].forEach((id) => { $(id).value = ''; });
loadStaffPending(host);
} catch (err) {
if (!handleApiError(err, 'staff-add')) toast(`Could not create account: ${err.message}`, 'danger');
} finally {
busy($('staffAdd'), false, 'Create account');
}
}
// ── Route approvals ──────────────────────────────────────────────────────
// transit/route/approve.php accepts approve | suspend | reject and refuses a
// no-op transition, so each decision is confirmed against the route's stops.
async function renderRouteApprovals(host) {
host.innerHTML = '';
let routes = [];
try {
const payload = await api('/Admin/transit/route/pending.php');
routes = payload?.routes || [];
} catch (err) {
if (handleApiError(err, 'routes')) return;
host.innerHTML = ``;
return;
}
if (!routes.length) {
host.innerHTML = 'No routes are waiting for approval.
';
return;
}
host.innerHTML = routes.map((route, index) => `
${esc(route.direction || '—')}
Direction
${fmtNum(route.distance_km)} km
Distance
${fmtInt(route.duration_min)} min
Duration
${fmtInt(route.stops_count)}
Stops
${esc(route.country || '—')}
Country
${esc(fmtDate(route.created_at, true))}
Submitted
Stops
${(route.stops || []).length ? `
${route.stops.map((s) => `
${esc(s.name_ar || 'Unnamed stop')}
${Number(s.is_major) ? 'major ' : ''}
${esc(shortCoord(`${s.latitude},${s.longitude}`))}
`).join('')}
` : '
This route has no stops recorded.
'}
`).join('');
host.querySelectorAll('[data-route-action]').forEach((btn) =>
btn.addEventListener('click', () =>
decideRoute(routes[Number(btn.dataset.route)], btn.dataset.routeAction, host)));
}
async function decideRoute(route, action, host) {
const verb = action === 'approve' ? 'approve' : 'reject';
const consequence = action === 'approve'
? 'The route goes live and passengers can ride it.'
: 'The organisation will have to resubmit the route.';
if (!confirm(
`${verb === 'approve' ? 'Approve' : 'Reject'} route "${route.name_ar || route.id}" ` +
`from ${route.org_name || 'this organisation'}?\n\n` +
`${fmtInt(route.stops_count)} stops · ${fmtNum(route.distance_km)} km\n\n${consequence}`
)) return;
try {
await api('/Admin/transit/route/approve.php', {
params: { route_id: route.id, action },
});
toast(`Route #${route.id} ${verb}ed.`, 'success');
renderRouteApprovals(host);
} catch (err) {
if (!handleApiError(err, 'route-decision')) toast(err.message, 'danger');
}
}
// ── Broadcast notifications ──────────────────────────────────────────────
// Goes through Admin/notifications/broadcast.php, never the internal FCM
// endpoint: the browser must not hold the internal API key.
function renderBroadcast(host) {
host.innerHTML = `
This reaches every device at once and cannot be recalled.
The message is recorded in the audit log against your account.
Audience
All captains
All passengers
Title max 120
Message max 1000
Review & send
Siro
Notification title
Message text
`;
const title = $('bcTitle');
const body = $('bcBody');
const sync = () => {
$('bcPreviewTitle').textContent = title.value.trim() || 'Notification title';
$('bcPreviewBody').textContent = body.value.trim() || 'Message text';
};
title.addEventListener('input', sync);
body.addEventListener('input', sync);
$('bcSend').addEventListener('click', () => sendBroadcast(host));
}
async function sendBroadcast(host) {
if (!isSuperAdmin()) {
toast('Broadcasting is restricted to super admins.', 'warning');
return;
}
const audience = $('bcAudience').value;
const title = $('bcTitle').value.trim();
const body = $('bcBody').value.trim();
const status = $('bcStatus');
const audienceLabel = audience === 'drivers' ? 'every captain' : 'every passenger';
if (!title || !body) {
toast('Enter both a title and a message.', 'warning');
return;
}
if (!confirm(
`Send this notification to ${audienceLabel} on the platform?\n\n` +
`${title}\n${body}\n\n` +
'It is delivered immediately and cannot be recalled.'
)) return;
busy($('bcSend'), true, 'Sending…');
status.textContent = '';
try {
const result = await api('/Admin/notifications/broadcast.php', {
params: { audience, title, body },
});
status.textContent = `Sent to ${audienceLabel} at ${new Date().toLocaleTimeString()}`;
toast(`Notification delivered to ${audienceLabel}.`, 'success');
$('bcTitle').value = '';
$('bcBody').value = '';
$('bcPreviewTitle').textContent = 'Notification title';
$('bcPreviewBody').textContent = 'Message text';
console.info('[broadcast]', result);
} catch (err) {
if (!handleApiError(err, 'broadcast')) toast(`Send failed: ${err.message}`, 'danger');
} finally {
busy($('bcSend'), false, 'Review & send');
}
}
// ── Kazan tariff editor ──────────────────────────────────────────────────
// Only these columns are accepted by ride/kazan/update.php; anything else
// sent would be silently dropped, so the form mirrors that list exactly.
const TARIFF_FIELDS = [
{ key: 'kazanPercent', label: 'Platform commission', hint: '% taken by Siro' },
{ key: 'fuelPrice', label: 'Fuel price' },
{ key: 'currency', label: 'Currency', type: 'text' },
{ key: 'normalMinPrice', label: 'Minimum fare — normal' },
{ key: 'peakMinPrice', label: 'Minimum fare — peak' },
{ key: 'lateMinPrice', label: 'Minimum fare — late night' },
{ key: 'fixedPrice', label: 'Fixed price' },
{ key: 'speedPrice', label: 'Speed' },
{ key: 'comfortPrice', label: 'Comfort' },
{ key: 'ladyPrice', label: 'Lady' },
{ key: 'electricPrice', label: 'Electric' },
{ key: 'vanPrice', label: 'Van' },
{ key: 'deliveryPrice', label: 'Delivery' },
{ key: 'mishwarVipPrice', label: 'Mishwar VIP' },
{ key: 'awfarPrice', label: 'Awfar' },
];
let tariffRows = [];
async function renderTariffEditor(host) {
host.innerHTML = '';
try {
const payload = await api('/ride/kazan/get.php');
tariffRows = Array.isArray(payload) ? payload : normaliseRows(payload);
} catch (err) {
if (handleApiError(err, 'tariff')) return;
host.innerHTML = ``;
return;
}
if (!tariffRows.length) {
host.innerHTML = 'No tariff rows configured.
';
return;
}
const readOnly = !isSuperAdmin();
host.innerHTML = `
${readOnly ? `
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.
`}
${tariffRows.map((row, index) => tariffCard(row, index, readOnly)).join('')}`;
if (readOnly) return;
host.querySelectorAll('[data-tariff-save]').forEach((btn) =>
btn.addEventListener('click', () => saveTariff(Number(btn.dataset.tariffSave), host)));
host.querySelectorAll('[data-tariff-reset]').forEach((btn) =>
btn.addEventListener('click', () => renderTariffEditor(host)));
}
function tariffCard(row, index, readOnly) {
const fields = TARIFF_FIELDS.filter((f) => row[f.key] !== undefined);
return `
`;
}
async function saveTariff(index, host) {
if (!isSuperAdmin()) {
toast('Only a super admin can change pricing.', 'warning');
return;
}
const row = tariffRows[index];
const inputs = host.querySelectorAll(`[data-tariff-input="${index}"]`);
const changes = {};
inputs.forEach((input) => {
const field = input.dataset.field;
const current = String(row[field] ?? '');
const next = input.value.trim();
if (next !== current) changes[field] = next;
});
if (!Object.keys(changes).length) {
toast('Nothing changed on this tariff row.', 'info');
return;
}
const summary = Object.entries(changes)
.map(([field, value]) => {
const label = TARIFF_FIELDS.find((f) => f.key === field)?.label || field;
return `• ${label}: ${row[field] ?? '—'} → ${value}`;
})
.join('\n');
const confirmed = confirm(
`Apply these pricing changes to "${row.country || 'tariff'}" (row #${row.id})?\n\n` +
`${summary}\n\n` +
'This takes effect immediately for passengers.'
);
if (!confirmed) return;
try {
await api('/ride/kazan/update.php', {
params: { id: row.id, adminId: session?.id ?? '', ...changes },
});
toast('Tariff updated and recorded in the audit log.', 'success');
renderTariffEditor(host);
} catch (err) {
if (!handleApiError(err, 'tariff-save')) toast(`Update failed: ${err.message}`, 'danger');
}
}
function cssEscape(value) {
return String(value).replace(/["\\]/g, '\\$&');
}
// Renders whatever the endpoint returned: scalars become KPI tiles, arrays
// of objects become tables, and nested objects recurse under their key.
function renderPayload(host, payload, depth = 0) {
host.innerHTML = '';
host.appendChild(buildNode(payload, depth));
}
function buildNode(payload, depth) {
const frag = document.createDocumentFragment();
if (payload === null || payload === undefined || payload === '') {
frag.appendChild(msgNode('The endpoint responded successfully but returned no data yet.'));
return frag;
}
if (Array.isArray(payload)) {
frag.appendChild(payload.length
? buildTable(payload)
: msgNode('No records recorded for this yet — the table is empty in the database.'));
return frag;
}
if (typeof payload !== 'object') {
frag.appendChild(msgNode(String(payload)));
return frag;
}
const scalars = [];
const nested = [];
Object.entries(payload).forEach(([key, value]) => {
if (value !== null && typeof value === 'object') nested.push([key, value]);
else scalars.push([key, value]);
});
if (scalars.length) {
const grid = document.createElement('div');
grid.className = 'kpi-tiles';
grid.innerHTML = scalars.map(([k, v]) => `
${esc(/status|state/i.test(k) ? labelStatus(v) : formatValue(v, k))}
${esc(humanize(k))}
`).join('');
frag.appendChild(grid);
}
nested.forEach(([key, value]) => {
const wrap = document.createElement('div');
wrap.className = 'sub-panel';
const heading = document.createElement('h4');
heading.className = 'sub-panel-title';
heading.textContent = humanize(key);
wrap.appendChild(heading);
wrap.appendChild(buildNode(value, depth + 1));
frag.appendChild(wrap);
});
return frag;
}
function buildTable(rows) {
const objects = rows.every((r) => r && typeof r === 'object' && !Array.isArray(r));
if (!objects) {
const list = document.createElement('div');
list.className = 'mini-list';
list.innerHTML = rows.map((r) => `${esc(String(r))}
`).join('');
return list;
}
const columns = [...new Set(rows.flatMap((r) => Object.keys(r)))]
.filter((c) => !/token|password|fingerprint/i.test(c))
.slice(0, 12);
const wrap = document.createElement('div');
wrap.className = 'table-responsive';
wrap.innerHTML = `
${columns.map((c) => `${esc(humanize(c))} `).join('')}
${rows.slice(0, 100).map((row) => `${columns.map((c) => `${cellHtml(c, row[c])} `).join('')} `).join('')}
`;
return wrap;
}
function cellHtml(column, value) {
if (value === null || value === undefined || value === '') return '— ';
if (typeof value === 'object') return `${esc(JSON.stringify(value).slice(0, 60))} `;
if (/status|type|state|result/i.test(column)) {
return `${esc(labelStatus(value))} `;
}
return esc(formatValue(value, column));
}
function formatValue(value, column = '') {
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
if (value === null || value === undefined || value === '') return '—';
const str = String(value);
if (/^\d{4}-\d{2}-\d{2}[ T]/.test(str)) return fmtDate(str);
if (/(price|fare|amount|revenue|earning|balance|payout|commission|total_paid)/i.test(column) && !isNaN(Number(str))) {
return fmtMoney(str);
}
// Identifiers, phones and codes are digit strings that must never be
// grouped with thousand separators — "0790000000" is not 790,000,000.
const isIdentifier = /(^|_)(id|phone|code|number|otp|year|zip|lat|lng|latitude|longitude)($|_)/i.test(column);
if (!isIdentifier && !str.startsWith('0') && /^-?\d+(\.\d+)?$/.test(str) && str.length < 12) {
return fmtNum(str);
}
return str;
}
function humanize(key) {
return String(key)
.replace(/[_-]+/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/\b\w/g, (m) => m.toUpperCase())
.trim();
}
function msgNode(text) {
const node = document.createElement('div');
node.className = 'table-msg';
node.textContent = text;
return node;
}
// ── Diagnostics ──────────────────────────────────────────────────────────
// Prints exactly what the server replies for every endpoint the console
// uses, so a blank dashboard can be traced to a status code instead of a
// guess: 404 = wrong API base, 403 = role/device rejection, 401 = token.
const PROBES = [
['Dashboard stats', '/Admin/dashbord.php'],
['Rides', '/Admin/rides/get_rides_by_status.php', { status: 'All' }],
['Captains', '/Admin/AdminCaptain/get.php', { page: 1 }],
['Passengers', '/Admin/getPassengerDetails.php'],
['Pending approvals', '/Admin/Staff/pending.php'],
['Rides per month', '/Admin/AdminRide/getRidesPerMonth.php'],
];
async function runDiagnostics() {
const out = el.diagnosticsOutput;
if (!out) return;
busy(el.runDiagnosticsBtn, true, 'Running…');
const lines = [
`Siro Admin diagnostics — ${new Date().toISOString()}`,
`Console build: ${BUILD}`,
`Page origin : ${location.origin}`,
`API base : ${API_BASE}`,
`Fingerprint : ${deviceFingerprint.slice(0, 20)}…`,
`Token : ${session?.jwt ? 'present (role ' + session.role + ')' : 'MISSING — not signed in'}`,
'─'.repeat(64),
];
for (const [name, path, params] of PROBES) {
const url = API_BASE + path;
const started = performance.now();
let form = null;
if (params) {
form = new FormData();
Object.entries(params).forEach(([k, v]) => form.append(k, v));
}
try {
const res = await fetch(url, {
method: form ? 'POST' : 'GET',
body: form,
headers: {
'X-Device-FP': deviceFingerprint,
...(session?.jwt ? { Authorization: `Bearer ${session.jwt}` } : {}),
},
});
const text = await res.text();
const ms = Math.round(performance.now() - started);
lines.push(
`${res.ok ? '✔' : '✘'} ${name}`,
` ${url}`,
` HTTP ${res.status} ${res.statusText} · ${ms}ms · ${text.length} bytes`,
` ${collapse(text).slice(0, 400)}`,
''
);
} catch (err) {
lines.push(
`✘ ${name}`,
` ${url}`,
` NETWORK FAILURE — ${err.message}`,
' (blocked by CORS, DNS, mixed content, or the host is unreachable)',
''
);
}
}
out.textContent = lines.join('\n');
busy(el.runDiagnosticsBtn, false, 'Run diagnostics');
}
// Outbound message — confirmed explicitly because it reaches a real person
// and cannot be recalled.
async function sendWhatsApp() {
if (!isSuperAdmin()) {
toast('Sending messages is restricted to super admins.', 'warning');
return;
}
const receiver = $('waReceiver').value.trim();
const message = $('waMessage').value.trim();
const status = $('waStatus');
if (!receiver || !message) {
toast('Enter both a recipient and a message.', 'warning');
return;
}
if (!confirm(`Send this WhatsApp message to ${receiver}?\n\n${message}`)) return;
status.textContent = 'Sending…';
try {
await api('/Admin/send_whatsapp_message.php', { params: { receiver, message } });
status.textContent = `Sent to ${receiver}`;
$('waMessage').value = '';
toast('Message sent.', 'success');
} catch (err) {
status.textContent = '';
toast(`Send failed: ${err.message}`, 'danger');
}
}
// Super-admin only: mirrors the Flutter EncryptToolPage (Admin/ggg.php).
async function runCryptoTool(action) {
if (!isSuperAdmin()) {
toast('This tool is restricted to super admins.', 'warning');
return;
}
const text = el.decryptInput.value.trim();
const adminPhone = $('decryptPhone').value.trim();
if (!text || !adminPhone) {
toast('Enter both your admin phone and the value.', 'warning');
return;
}
el.decryptOutput.textContent = 'Working…';
try {
const payload = await api('/Admin/ggg.php', {
params: { action, text, admin_phone: adminPhone },
});
el.decryptOutput.textContent = payload?.result ?? JSON.stringify(payload, null, 2);
} catch (err) {
el.decryptOutput.textContent = `Failed: ${err.message}`;
}
}
function collapse(text) {
return String(text).replace(/\s+/g, ' ').trim() || '(empty response body)';
}
function setupDiagnostics() {
if (!el.apiBaseSelect) return;
el.apiBaseSelect.innerHTML = API_CANDIDATES
.map((c) => `${esc(c.label)} `)
.join('') + 'Custom… ';
const known = API_CANDIDATES.some((c) => c.value === API_BASE);
el.apiBaseSelect.value = known ? API_BASE : '__custom__';
el.apiBaseCustom.value = known ? '' : API_BASE;
el.apiBaseCustom.hidden = known;
el.apiBaseSelect.addEventListener('change', () => {
el.apiBaseCustom.hidden = el.apiBaseSelect.value !== '__custom__';
});
el.saveApiBaseBtn.addEventListener('click', () => {
const chosen = el.apiBaseSelect.value === '__custom__'
? el.apiBaseCustom.value.trim().replace(/\/$/, '')
: el.apiBaseSelect.value;
if (!chosen) return;
API_BASE = chosen;
localStorage.setItem(API_BASE_KEY, chosen);
toast(`API base set to ${chosen}. Reloading data…`, 'success');
renderSessionInfo();
if (session) loadEverything();
});
el.runDiagnosticsBtn.addEventListener('click', runDiagnostics);
document.querySelectorAll('[data-action="decrypt"], [data-action="encrypt"]').forEach((btn) =>
btn.addEventListener('click', () => runCryptoTool(btn.dataset.action)));
$('waSendBtn')?.addEventListener('click', sendWhatsApp);
el.copyDiagnosticsBtn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(el.diagnosticsOutput.textContent);
toast('Diagnostics report copied.', 'success');
} catch {
toast('Copy failed — select the text manually.', 'warning');
}
});
}
function showRideDetails(r) {
const body = $('modalBodyContent');
const rows = [
['Status', labelStatus(r.status)],
['Passenger', r.passenger_full_name],
['Passenger phone', maskPhone(r.p_phone)],
['Completed trips (passenger)', fmtInt(r.p_completed)],
['Captain', r.driver_full_name],
['Captain phone', maskPhone(r.d_phone)],
['Captain completed / cancelled', `${fmtInt(r.d_completed)} / ${fmtInt(r.d_canceled)}`],
['Pickup', r.address_start || r.start_location],
['Drop-off', r.address_end || r.end_location],
['Distance', r.distance ? `${fmtNum(r.distance)} km` : '—'],
['Passenger fare', fmtMoney(rideFare(r))],
['Captain earning', r.price_for_driver ? fmtMoney(r.price_for_driver) : '—'],
['Payment method', r.paymentMethod],
['Service', r.carType],
['Requested', rideTimestamp(r)],
['Started', r.rideTimeStart ? fmtDate(r.rideTimeStart) : '—'],
['Finished', r.rideTimeFinish ? fmtDate(r.rideTimeFinish) : '—'],
['Cancellation note', r.cancel_reason],
];
body.innerHTML = `
Trip #${esc(r.id)}
${rows.map(([k, v]) => `
${k} ${esc(String(v ?? '—') || '—')}
`).join('')}
`;
$('detailsModal').classList.add('active');
}
window.closeModal = () => $('detailsModal')?.classList.remove('active');
// ── Charts (dependency-free canvas rendering) ────────────────────────────
const chartData = new Map();
function prepareCanvas(id) {
const canvas = $(id);
if (!canvas || !canvas.parentElement) return null;
const ratio = window.devicePixelRatio || 1;
// Collapse the canvas first: a sized canvas props its own container open,
// so measuring before resetting would make charts grow but never shrink.
canvas.width = 0;
canvas.height = 0;
const w = canvas.parentElement.clientWidth;
const h = canvas.parentElement.clientHeight;
canvas.width = w * ratio;
canvas.height = h * ratio;
const ctx = canvas.getContext('2d');
ctx.scale(ratio, ratio);
ctx.clearRect(0, 0, w, h);
return { ctx, w, h };
}
function drawLineChart(id, series) {
chartData.set(id, { type: 'line', series });
const c = prepareCanvas(id);
if (!c || !series.length) return;
const { ctx, w, h } = c;
const padX = 34, padTop = 16, padBottom = 26;
const max = Math.max(...series.map((s) => s.value), 1);
const stepX = series.length > 1 ? (w - padX * 2) / (series.length - 1) : 0;
const y = (v) => h - padBottom - (v / max) * (h - padTop - padBottom);
const pts = series.map((s, i) => ({ x: padX + i * stepX, y: y(s.value) }));
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
ctx.fillStyle = '#64748b';
ctx.font = '10px Inter, sans-serif';
for (let i = 0; i <= 4; i++) {
const gy = padTop + i * (h - padTop - padBottom) / 4;
ctx.beginPath(); ctx.moveTo(padX, gy); ctx.lineTo(w - padX + 10, gy); ctx.stroke();
ctx.textAlign = 'right';
ctx.fillText(String(Math.round(max - i * max / 4)), padX - 6, gy + 3);
}
const grad = ctx.createLinearGradient(0, padTop, 0, h - padBottom);
grad.addColorStop(0, 'rgba(99,102,241,0.35)');
grad.addColorStop(1, 'rgba(99,102,241,0)');
ctx.beginPath();
pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)));
ctx.lineTo(pts[pts.length - 1].x, h - padBottom);
ctx.lineTo(pts[0].x, h - padBottom);
ctx.closePath();
ctx.fillStyle = grad;
ctx.fill();
ctx.beginPath();
pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)));
ctx.strokeStyle = '#6366f1';
ctx.lineWidth = 2.5;
ctx.lineJoin = 'round';
ctx.stroke();
const last = pts[pts.length - 1];
ctx.beginPath();
ctx.arc(last.x, last.y, 4.5, 0, Math.PI * 2);
ctx.fillStyle = '#6366f1';
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.stroke();
ctx.fillStyle = '#64748b';
ctx.textAlign = 'center';
const every = Math.ceil(series.length / 7);
series.forEach((s, i) => {
if (i % every === 0 || i === series.length - 1) ctx.fillText(s.label, pts[i].x, h - 8);
});
}
// Multiple series on one axis, sharing a scale so the lines are comparable.
function drawMultiLine(id, seriesList) {
chartData.set(id, { type: 'multi', series: seriesList });
const c = prepareCanvas(id);
if (!c || !seriesList.length || !seriesList[0].points.length) return;
const { ctx, w, h } = c;
const padL = 38, padR = 14, padTop = 14, padBottom = 26;
const count = seriesList[0].points.length;
const max = Math.max(1, ...seriesList.flatMap((s) => s.points.map((p) => p.value)));
const stepX = count > 1 ? (w - padL - padR) / (count - 1) : 0;
const y = (v) => h - padBottom - (v / max) * (h - padTop - padBottom);
// grid + scale
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
ctx.fillStyle = '#64748b';
ctx.font = '10px Inter, sans-serif';
const ticks = Math.min(4, max);
for (let i = 0; i <= ticks; i++) {
const gy = padTop + i * (h - padTop - padBottom) / ticks;
ctx.beginPath(); ctx.moveTo(padL, gy); ctx.lineTo(w - padR, gy); ctx.stroke();
ctx.textAlign = 'right';
ctx.fillText(String(Math.round(max - i * max / ticks)), padL - 6, gy + 3);
}
seriesList.forEach((serie) => {
const pts = serie.points.map((p, i) => ({ x: padL + i * stepX, y: y(p.value) }));
const grad = ctx.createLinearGradient(0, padTop, 0, h - padBottom);
grad.addColorStop(0, serie.color + '55');
grad.addColorStop(1, serie.color + '00');
ctx.beginPath();
pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)));
ctx.lineTo(pts[pts.length - 1].x, h - padBottom);
ctx.lineTo(pts[0].x, h - padBottom);
ctx.closePath();
ctx.fillStyle = grad;
ctx.fill();
ctx.beginPath();
pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)));
ctx.strokeStyle = serie.color;
ctx.lineWidth = 2.2;
ctx.lineJoin = 'round';
ctx.stroke();
// mark only non-zero days: dots on a flat zero line are just noise
serie.points.forEach((p, i) => {
if (!p.value) return;
ctx.beginPath();
ctx.arc(pts[i].x, pts[i].y, 3.2, 0, Math.PI * 2);
ctx.fillStyle = serie.color;
ctx.fill();
});
});
ctx.fillStyle = '#64748b';
ctx.textAlign = 'center';
const every = Math.ceil(count / 8);
seriesList[0].points.forEach((p, i) => {
if (i % every === 0 || i === count - 1) ctx.fillText(p.label, padL + i * stepX, h - 8);
});
}
function drawBarChart(id, series) {
chartData.set(id, { type: 'bar', series });
const c = prepareCanvas(id);
if (!c || !series.length) return;
const { ctx, w, h } = c;
const padBottom = 28, padTop = 14;
const max = Math.max(...series.map((s) => s.value), 1);
const slot = w / series.length;
const barW = Math.min(64, slot * 0.5);
series.forEach((s, i) => {
const barH = (s.value / max) * (h - padTop - padBottom);
const x = i * slot + (slot - barW) / 2;
const yTop = h - padBottom - barH;
const grad = ctx.createLinearGradient(0, yTop, 0, h - padBottom);
grad.addColorStop(0, '#6366f1');
grad.addColorStop(1, 'rgba(99,102,241,0.25)');
ctx.fillStyle = grad;
roundRect(ctx, x, yTop, barW, barH, 6);
ctx.fill();
ctx.fillStyle = '#f8fafc';
ctx.font = '600 11px Inter, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(fmtInt(s.value), x + barW / 2, yTop - 5);
ctx.fillStyle = '#64748b';
ctx.font = '10px Inter, sans-serif';
ctx.fillText(s.label, x + barW / 2, h - 9);
});
}
function drawDonut(id, slices, legendEl) {
chartData.set(id, { type: 'donut', series: slices, legendEl });
const c = prepareCanvas(id);
if (!c) return;
const { ctx, w, h } = c;
const total = slices.reduce((s, x) => s + x.value, 0);
const cx = w / 2, cy = h / 2;
const r = Math.min(w, h) / 2 - 8;
const inner = r * 0.62;
if (!total) {
ctx.fillStyle = '#64748b';
ctx.font = '12px Inter, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('No ride data', cx, cy);
} else {
let angle = -Math.PI / 2;
slices.forEach((s) => {
const sweep = (s.value / total) * Math.PI * 2;
ctx.beginPath();
ctx.arc(cx, cy, r, angle, angle + sweep);
ctx.arc(cx, cy, inner, angle + sweep, angle, true);
ctx.closePath();
ctx.fillStyle = s.color;
ctx.fill();
angle += sweep;
});
ctx.fillStyle = '#f8fafc';
ctx.font = '600 18px Outfit, Inter, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(fmtInt(total), cx, cy + 2);
ctx.fillStyle = '#64748b';
ctx.font = '10px Inter, sans-serif';
ctx.fillText('TOTAL RIDES', cx, cy + 18);
}
if (legendEl) {
legendEl.innerHTML = slices.map((s) => `
${s.label}
${total ? ((s.value / total) * 100).toFixed(1) : '0.0'}%
`).join('');
}
}
function roundRect(ctx, x, y, w, h, r) {
const radius = Math.min(r, h / 2, w / 2);
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.arcTo(x + w, y, x + w, y + h, radius);
ctx.arcTo(x + w, y + h, x, y + h, radius);
ctx.arcTo(x, y + h, x, y, radius);
ctx.arcTo(x, y, x + w, y, radius);
ctx.closePath();
}
function redrawCharts() {
chartData.forEach((cfg, id) => {
if (cfg.type === 'multi') drawMultiLine(id, cfg.series);
else if (cfg.type === 'line') drawLineChart(id, cfg.series);
else if (cfg.type === 'bar') drawBarChart(id, cfg.series);
else drawDonut(id, cfg.series, cfg.legendEl);
});
}
// ── Navigation & misc events ─────────────────────────────────────────────
function setupNavigation() {
const navItems = document.querySelectorAll('.nav-item[data-view]');
navItems.forEach((item) => {
item.addEventListener('click', (e) => {
e.preventDefault();
navItems.forEach((n) => n.classList.remove('active'));
item.classList.add('active');
document.querySelectorAll('.page-view').forEach((v) =>
v.classList.toggle('active', v.id === item.dataset.view));
if (window.innerWidth <= 992) el.sidebar.classList.remove('open');
redrawCharts();
// Opening a section always refetches it: an operator switching to a
// list expects what the database holds now, not what it held when the
// console was first opened.
if (session) refreshView(item.dataset.view, item.dataset.module);
});
});
el.toggleSidebar?.addEventListener('click', () => el.sidebar.classList.toggle('open'));
}
// Maps a sidebar entry to the loader that owns its data.
const VIEW_LOADERS = {
dashboardView: () => { loadStats().catch(() => {}); loadRidesTrend().catch(() => {}); },
ridesView: () => loadRides().catch(() => {}),
driversView: () => loadDrivers().catch(() => {}),
passengersView: () => loadPassengers().catch(() => {}),
financialsView: () => loadStats().catch(() => {}),
complaintsView: () => loadStats().catch(() => {}),
approvalsView: () => loadApprovals().catch(() => {}),
systemView: () => renderSessionInfo(),
};
function refreshView(viewId, moduleId) {
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';
});
});
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(redrawCharts, 150);
});
}
function bindSearch(input, button, onSearch, onClear) {
if (!input) return;
const run = () => {
const term = input.value.trim();
if (term) onSearch(term);
else onClear();
};
button?.addEventListener('click', run);
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') run(); });
input.addEventListener('input', () => { if (!input.value.trim()) onClear(); });
}
// ── Small helpers ────────────────────────────────────────────────────────
function setKpi(key, value) {
document.querySelectorAll(`[data-kpi="${key}"]`).forEach((n) => { n.textContent = value; });
}
function markKpisUnavailable() {
document.querySelectorAll('[data-kpi]').forEach((n) => { n.textContent = 'n/a'; });
}
function tableMessage(tbody, cols, message, isError = false) {
if (!tbody) return;
tbody.innerHTML = `${esc(message)} `;
}
function setConnection(state, text) {
if (!el.connectionPill) return;
el.connectionPill.dataset.state = state;
el.connectionText.textContent = text;
}
function busy(btn, isBusy, label) {
if (!btn) return;
btn.disabled = isBusy;
const span = btn.querySelector('span');
if (span) span.textContent = label;
}
const num = (v) => Number(v) || 0;
const fmtInt = (v) => num(v).toLocaleString('en-US');
const fmtNum = (v) => num(v).toLocaleString('en-US', { maximumFractionDigits: 2 });
const fmtMoney = (v) => num(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' JOD';
function fmtDate(value, dateOnly = false) {
if (!value) return '—';
const d = new Date(String(value).replace(' ', 'T'));
if (isNaN(d)) return String(value);
return dateOnly ? d.toLocaleDateString() : d.toLocaleString();
}
function rating(v) {
const r = Number(v);
if (!r) return 'unrated ';
return ` ${r.toFixed(2)}`;
}
function shorten(text, max = 26) {
if (!text) return '—';
const s = String(text);
return s.length > max ? s.slice(0, max - 1) + '…' : s;
}
// Two status generations coexist in the ride table: the legacy CamelCase set
// and the lowercase set written by the current ride pipeline.
const STATUS_LABELS = {
finished: 'Completed', completed: 'Completed',
begin: 'In progress', started: 'In progress',
apply: 'Captain assigned', applied: 'Captain assigned',
accepted: 'Captain assigned', claimed: 'Captain assigned',
arrived: 'Captain arrived',
new: 'Waiting', nothing: 'Waiting', waiting: 'Waiting',
wait: 'Waiting', pending: 'Waiting', searching: 'Searching for a captain',
cancel: 'Cancelled',
cancelfromdriver: 'Cancelled by captain',
cancelfromdriverafterapply: 'Cancelled by captain',
cancelfrompassenger: 'Cancelled by passenger',
cancelled_by_driver: 'Cancelled by captain',
cancelled_by_passenger: 'Cancelled by passenger',
cancelled_no_driver_found: 'No captain found',
timeout: 'Timed out', refused: 'Refused',
pending_review: 'Pending review',
};
function labelStatus(status) {
if (!status) return 'Unknown';
return t(STATUS_LABELS[String(status).toLowerCase()] || humanize(status));
}
function badgeClass(status) {
const s = String(status || '').toLowerCase();
if (['finished', 'completed', 'active', 'approved', 'online', 'success'].includes(s)) return 'badge-success';
if (s.startsWith('cancel') || ['timeout', 'refused', 'suspended', 'rejected', 'blocked', 'failure', 'error'].includes(s)) return 'badge-danger';
if (['begin', 'apply', 'applied', 'accepted', 'claimed', 'started', 'arrived'].includes(s)) return 'badge-primary';
if (['pending', 'pending_review', 'new', 'wait', 'waiting', 'nothing', 'searching'].includes(s)) return 'badge-warning';
return 'badge-info';
}
function formatRole(role) {
return String(role || '').replace(/_/g, ' ').replace(/\b\w/g, (m) => m.toUpperCase()) || 'Admin';
}
function initials(name) {
return String(name || 'A').trim().split(/\s+/).slice(0, 2).map((w) => w[0]).join('').toUpperCase();
}
function esc(value) {
return String(value ?? '').replace(/[&<>"']/g, (c) =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
}
function toast(message, type = 'info') {
const icons = { success: 'ph-check-circle', danger: 'ph-warning-octagon', warning: 'ph-warning', info: 'ph-info' };
const node = document.createElement('div');
node.className = `toast toast-${type}`;
node.innerHTML = `${esc(message)} `;
document.body.appendChild(node);
setTimeout(() => {
node.classList.add('leaving');
setTimeout(() => node.remove(), 300);
}, 4200);
}
})();