Eight queries across the v2 modules counted only status = 'Finished' and so reported zero on live data, where the current ride pipeline writes 'completed': realtime revenue for today and yesterday, financial stats, settlements, driver scorecard, driver ranking, and both revenue queries. All now match either spelling. Growth and Advanced Analytics rendered through the generic shape-detecting renderer, which produced raw tables that said little. Both now have purpose- built views: - Growth: totals, 30-day joins, and a two-series daily chart. growth.php only returns days that had signups, so the series is expanded to a continuous 30-day axis with explicit zeros — plotting the returned rows directly would hide the gaps and make a quiet month look like steady growth. A caption states how many days actually had a signup. - Analytics: revenue summary tiles, a daily revenue trend, and the captain ranking, with a note explaining that platform share is what remains after the captain's cut. Null aggregates render as "—" rather than 0.00, and markers are drawn only on days with a value so a flat zero line stays readable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3216 lines
133 KiB
JavaScript
3216 lines
133 KiB
JavaScript
/* ==========================================================================
|
||
Siro Admin Console — application engine
|
||
All figures rendered here come from the production API. There is no
|
||
mock/demo dataset: when a request fails the table says so instead of
|
||
showing invented numbers.
|
||
========================================================================== */
|
||
|
||
(() => {
|
||
'use strict';
|
||
|
||
// Bump together with the ?v= query in index.html. Shown in the UI and in the
|
||
// diagnostics report so "the deploy did nothing" can be answered with a fact
|
||
// rather than a guess about caching.
|
||
const BUILD = '2026-07-25-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 = `<i class="ph-fill ph-warning-octagon"></i><div>
|
||
<strong>${esc(message)}</strong>
|
||
${detail ? `<span>${esc(detail)}</span>` : ''}
|
||
</div>`;
|
||
}
|
||
|
||
function clearLoginError() {
|
||
if (el.loginError) el.loginError.hidden = true;
|
||
}
|
||
|
||
async function onLogin(e) {
|
||
e.preventDefault();
|
||
const phone = el.loginEmail.value.trim();
|
||
const password = el.loginPass.value;
|
||
if (!phone || !password) return;
|
||
|
||
clearLoginError();
|
||
busy(el.loginSubmitBtn, true, t('Verifying…'));
|
||
try {
|
||
const form = new FormData();
|
||
form.append('phone', phone);
|
||
form.append('password', password);
|
||
form.append('fingerprint', deviceFingerprint);
|
||
form.append('aud', 'admin');
|
||
|
||
const payload = await api('/Admin/auth/login.php', { method: 'POST', body: form, auth: false });
|
||
|
||
if (payload?.status === 'otp_required') {
|
||
pendingPhone = phone;
|
||
el.otpPhoneText.textContent = `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) => `
|
||
<div class="mix-row">
|
||
<div class="mix-head">
|
||
<span>${t.label}</span>
|
||
<strong>${fmtInt(t.value)} <span class="mix-pct">${((t.value / total) * 100).toFixed(1)}%</span></strong>
|
||
</div>
|
||
<div class="mix-track"><div class="mix-fill" style="width:${(t.value / total) * 100}%; background:${t.color};"></div></div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function renderTripPerformance() {
|
||
const rows = [
|
||
[t('Average trip duration'), stats.driver_avg_duration || '—'],
|
||
[t('Longest trip duration'), stats.longest_duration || '—'],
|
||
[t('Average distance'), `${fmtNum(stats.average_distance)} km`],
|
||
[t('Longest distance'), `${fmtNum(stats.longest_distance)} km`],
|
||
[t('Cancelled rides'), fmtInt(stats.cancelled_rides)],
|
||
[t('Captains with completed trips'), fmtInt(stats.num_Driver)],
|
||
];
|
||
el.tripPerformance.innerHTML = rows.map(([k, v]) => `
|
||
<div class="kv-row"><span>${k}</span><strong>${v}</strong></div>
|
||
`).join('');
|
||
}
|
||
|
||
function renderRides() {
|
||
const rides = allRides.slice(0, ridesShown);
|
||
el.ridesMeta.textContent = allRides.length
|
||
? `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) => `
|
||
<tr>
|
||
<td><strong>#${esc(r.id)}</strong></td>
|
||
<td>${esc(r.passenger_full_name || 'Unknown')}</td>
|
||
<td>${esc(r.driver_full_name || 'Unassigned')}</td>
|
||
<td class="route-cell">${routeCell(r)}</td>
|
||
<td><strong>${fmtMoney(rideFare(r))}</strong></td>
|
||
<td><span class="badge ${badgeClass(r.status)}">${esc(labelStatus(r.status))}</span></td>
|
||
<td>${esc(rideTimestamp(r))}</td>
|
||
<td><button class="btn btn-secondary btn-sm" data-ride="${esc(r.id)}"><i class="ph ph-eye"></i></button></td>
|
||
</tr>`).join('');
|
||
|
||
el.ridesTableBody.querySelectorAll('[data-ride]').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
const ride = allRides.find((r) => String(r.id) === btn.dataset.ride);
|
||
if (ride) showRideDetails(ride);
|
||
});
|
||
});
|
||
}
|
||
|
||
// The ride table carries two generations of columns. Older rows expose
|
||
// address_start/address_end, price_for_passenger and created_at; rows written
|
||
// by the current ride pipeline expose start_location/end_location as
|
||
// "lat,lng" pairs, a plain `price`, and separate date + time columns.
|
||
function rideFare(r) {
|
||
return r.price_for_passenger ?? r.price ?? 0;
|
||
}
|
||
|
||
function rideTimestamp(r) {
|
||
if (r.created_at) return fmtDate(r.created_at);
|
||
if (r.date) return fmtDate(`${r.date} ${r.time && r.time !== '00:00:00' ? r.time : ''}`.trim());
|
||
return '—';
|
||
}
|
||
|
||
function routeCell(r) {
|
||
if (r.address_start || r.address_end) {
|
||
return `<i class="ph ph-map-pin"></i> ${esc(shorten(r.address_start))} <i class="ph ph-arrow-right"></i> ${esc(shorten(r.address_end))}`;
|
||
}
|
||
if (r.start_location) {
|
||
const link = mapLink(r.start_location);
|
||
return `<a class="coord-link" href="${esc(link)}" target="_blank" rel="noopener">
|
||
<i class="ph ph-map-pin"></i> ${esc(shortCoord(r.start_location))} <i class="ph ph-arrow-right"></i> ${esc(shortCoord(r.end_location))}
|
||
</a>`;
|
||
}
|
||
return '<span class="stamp">—</span>';
|
||
}
|
||
|
||
function shortCoord(value) {
|
||
if (!value) return '—';
|
||
return String(value).split(',').map((n) => Number(n).toFixed(4)).join(', ');
|
||
}
|
||
|
||
function mapLink(coords) {
|
||
return `https://www.openstreetmap.org/?mlat=${encodeURIComponent(String(coords).split(',')[0])}&mlon=${encodeURIComponent(String(coords).split(',')[1] || '')}#map=15/`;
|
||
}
|
||
|
||
// ── Role model ───────────────────────────────────────────────────────────
|
||
// Mirrors the Flutter admin app: a plain `admin` observes, a `super_admin`
|
||
// edits, approves and sees unmasked contact details.
|
||
const isSuperAdmin = () => session?.role === 'super_admin';
|
||
|
||
function maskPhone(phone) {
|
||
if (!phone || phone === '—') return '—';
|
||
if (isSuperAdmin()) return String(phone);
|
||
const s = String(phone);
|
||
return s.length > 6 ? `${s.slice(0, 4)}****${s.slice(-2)}` : '****';
|
||
}
|
||
|
||
function applyRoleVisibility() {
|
||
document.querySelectorAll('[data-requires-super]').forEach((node) => {
|
||
node.hidden = !isSuperAdmin();
|
||
});
|
||
}
|
||
|
||
// ── Lookup by phone / id ─────────────────────────────────────────────────
|
||
async function lookupRidesByPhone(phone) {
|
||
tableMessage(el.ridesTableBody, 8, `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 = `
|
||
<div class="modal-head">
|
||
<h3>${esc(title)}</h3>
|
||
<button class="btn-icon" onclick="closeModal()"><i class="ph ph-x"></i></button>
|
||
</div>
|
||
<div class="table-msg">Loading profile…</div>`;
|
||
$('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 = `
|
||
<div class="modal-head">
|
||
<h3>${esc(title)}</h3>
|
||
<button class="btn-icon" onclick="closeModal()"><i class="ph ph-x"></i></button>
|
||
</div>
|
||
<div class="mini-list">
|
||
${entries.map(([k, v]) => `<div class="kv-row"><span>${esc(k)}</span><strong>${esc(String(v))}</strong></div>`).join('')}
|
||
</div>`;
|
||
} catch (err) {
|
||
if (handleApiError(err, 'profile')) return;
|
||
body.innerHTML = `
|
||
<div class="modal-head">
|
||
<h3>${esc(title)}</h3>
|
||
<button class="btn-icon" onclick="closeModal()"><i class="ph ph-x"></i></button>
|
||
</div>
|
||
<div class="table-msg is-error">${esc(err.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function renderDrivers(drivers, total) {
|
||
el.driversMeta.textContent = `Page ${driversPage} of ${driversPages} · ${fmtInt(total)} captains`;
|
||
if (!drivers.length) {
|
||
tableMessage(el.driversTableBody, 8, 'No captains found.');
|
||
return;
|
||
}
|
||
el.driversTableBody.innerHTML = drivers.map((d) => `
|
||
<tr class="row-clickable" data-captain="${esc(d.id)}">
|
||
<td><strong>#${esc(d.id)}</strong></td>
|
||
<td>${esc(`${d.first_name || ''} ${d.last_name || ''}`.trim() || 'Unnamed')}</td>
|
||
<td>${esc(maskPhone(d.phone))}</td>
|
||
<td>${esc(d.email || '—')}</td>
|
||
<td>${rating(d.passengerAverageRating)}</td>
|
||
<td>${fmtInt(d.countPassengerRide)}</td>
|
||
<td>${fmtInt(d.countPassengerCancel)}</td>
|
||
<td><span class="badge ${badgeClass(d.status)}">${esc(d.status || 'unknown')}</span></td>
|
||
</tr>
|
||
`).join('');
|
||
|
||
el.driversTableBody.querySelectorAll('[data-captain]').forEach((row) =>
|
||
row.addEventListener('click', () => openCaptainProfile(row.dataset.captain)));
|
||
}
|
||
|
||
function renderPassengers(rows) {
|
||
if (!rows.length) {
|
||
tableMessage(el.passengersTableBody, 8, 'No passengers found.');
|
||
return;
|
||
}
|
||
el.passengersTableBody.innerHTML = rows.map((p) => `
|
||
<tr class="row-clickable" data-passenger="${esc(p.id)}">
|
||
<td><strong>#${esc(p.id)}</strong></td>
|
||
<td>${esc(`${p.first_name || ''} ${p.last_name || ''}`.trim() || 'Unnamed')}</td>
|
||
<td>${esc(p.email || maskPhone(p.phone))}</td>
|
||
<td>${fmtInt(p.countPassengerRide)}</td>
|
||
<td>${rating(p.passengerAverageRating)}</td>
|
||
<td>${fmtInt(p.countPassengerCancel)}</td>
|
||
<td>${esc(fmtDate(p.created_at, true))}</td>
|
||
<td><span class="badge ${badgeClass(p.status)}">${esc(p.status || 'unknown')}</span></td>
|
||
</tr>
|
||
`).join('');
|
||
|
||
el.passengersTableBody.querySelectorAll('[data-passenger]').forEach((row) =>
|
||
row.addEventListener('click', () => openPassengerProfile(row.dataset.passenger)));
|
||
}
|
||
|
||
function renderApprovals(pending) {
|
||
el.approvalsCount.hidden = pending.length === 0;
|
||
el.approvalsCount.textContent = pending.length;
|
||
|
||
if (!pending.length) {
|
||
tableMessage(el.approvalsTableBody, 7, 'No pending requests.');
|
||
return;
|
||
}
|
||
|
||
const isSuper = session?.role === 'super_admin';
|
||
el.approvalsTableBody.innerHTML = pending.map((p) => `
|
||
<tr>
|
||
<td><strong>#${esc(p.id)}</strong></td>
|
||
<td>${esc(p.name || '—')}</td>
|
||
<td>${esc(p.phone || '—')}</td>
|
||
<td><span class="badge badge-info">${esc(p.type)}</span></td>
|
||
<td>${esc(p.role || '—')}</td>
|
||
<td>${esc(fmtDate(p.created_at, true))}</td>
|
||
<td>${(isSuper && p.type === 'admin') ? `
|
||
<button class="btn btn-secondary btn-sm" data-approve="${esc(p.id)}"><i class="ph ph-check"></i> Approve</button>
|
||
<button class="btn btn-secondary btn-sm danger-btn" data-reject="${esc(p.id)}"><i class="ph ph-x"></i></button>
|
||
` : '<span class="stamp">super admin only</span>'}</td>
|
||
</tr>
|
||
`).join('');
|
||
|
||
el.approvalsTableBody.querySelectorAll('[data-approve]').forEach((b) =>
|
||
b.addEventListener('click', () => decideApproval(b.dataset.approve, 'approved')));
|
||
el.approvalsTableBody.querySelectorAll('[data-reject]').forEach((b) =>
|
||
b.addEventListener('click', () => decideApproval(b.dataset.reject, 'rejected')));
|
||
}
|
||
|
||
async function decideApproval(adminId, action) {
|
||
if (!confirm(`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]) =>
|
||
`<div class="kv-row"><span>${k}</span><strong>${esc(String(v))}</strong></div>`).join('');
|
||
}
|
||
|
||
// ── Extended modules (parity with the Flutter admin app) ────────────────
|
||
// Each entry becomes a sidebar item plus a lazily-loaded page. Panels are
|
||
// rendered by shape, not by hand-written field lists, so an endpoint that
|
||
// grows a column shows it without a code change here.
|
||
const MODULES = [
|
||
{
|
||
id: 'liveOps', group: 'Realtime & Analytics', icon: 'ph-broadcast', title: 'Live Operations',
|
||
subtitle: 'Realtime fleet counters and the alerts that need attention now',
|
||
panels: [
|
||
{ title: 'Realtime counters', path: '/Admin/v2/realtime_dashboard.php' },
|
||
{ title: 'Smart alerts', path: '/Admin/v2/smart_alerts.php' },
|
||
],
|
||
},
|
||
{
|
||
id: 'growth', group: 'Realtime & Analytics', icon: 'ph-trend-up', title: 'Growth',
|
||
subtitle: 'How many passengers and captains joined each day over the last 30 days',
|
||
custom: renderGrowth,
|
||
},
|
||
{
|
||
id: 'analyticsV2', group: 'Realtime & Analytics', icon: 'ph-chart-line', title: 'Advanced Analytics',
|
||
subtitle: 'Revenue, ranking and dashboard aggregates from the v2 engine',
|
||
custom: renderAnalytics,
|
||
},
|
||
{
|
||
id: 'financeV2', group: 'Finance', icon: 'ph-bank', title: 'Financial V2',
|
||
subtitle: 'Settlement runs and financial aggregates',
|
||
panels: [
|
||
{ title: 'Financial stats', path: '/Admin/v2/financial/stats.php' },
|
||
{ title: 'Settlements', path: '/Admin/v2/financial/settlements.php' },
|
||
],
|
||
},
|
||
{
|
||
id: 'marketing', group: 'Growth & Pricing', icon: 'ph-megaphone', title: 'Marketing Intelligence',
|
||
subtitle: 'Market share, competitor price gaps, anomalies and campaign history',
|
||
panels: [
|
||
{ title: 'Market share', path: '/Admin/marketing/get_market_share_analytics.php' },
|
||
{ 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 = `<i class="ph ${mod.icon}"></i><span>${esc(t(mod.title))}</span>`;
|
||
menu.appendChild(item);
|
||
|
||
const section = document.createElement('section');
|
||
section.className = 'page-view';
|
||
section.id = `mod_${mod.id}`;
|
||
section.innerHTML = `
|
||
<div class="page-header">
|
||
<div class="page-title">
|
||
<h1>${esc(t(mod.title))}</h1>
|
||
<p>${esc(t(mod.subtitle))}</p>
|
||
</div>
|
||
<div class="page-actions">
|
||
<button class="btn btn-secondary btn-sm" data-reload="${mod.id}">
|
||
<i class="ph ph-arrows-clockwise"></i> Reload
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="module-panels" id="panels_${mod.id}"></div>`;
|
||
main.appendChild(section);
|
||
|
||
section.querySelector('[data-reload]').addEventListener('click', () => loadModule(mod, true));
|
||
});
|
||
});
|
||
}
|
||
|
||
async function loadModule(mod, force = false) {
|
||
if (loadedModules.has(mod.id) && !force) return;
|
||
loadedModules.add(mod.id);
|
||
|
||
const host = $(`panels_${mod.id}`);
|
||
if (mod.custom) {
|
||
await mod.custom(host);
|
||
return;
|
||
}
|
||
|
||
host.innerHTML = mod.panels.map((p) => `
|
||
<div class="card" data-panel="${esc(p.path)}">
|
||
<div class="card-header"><h3 class="card-title">${esc(p.title)}</h3></div>
|
||
<div class="panel-body"><div class="table-msg">Loading…</div></div>
|
||
</div>`).join('');
|
||
|
||
await Promise.all(mod.panels.map(async (p) => {
|
||
const body = host.querySelector(`[data-panel="${cssEscape(p.path)}"] .panel-body`);
|
||
try {
|
||
const payload = await api(p.path, { params: p.params || null });
|
||
renderPayload(body, payload);
|
||
} catch (err) {
|
||
if (handleApiError(err, mod.id)) return;
|
||
body.innerHTML = `<div class="table-msg is-error">${esc(err.message)}</div>`;
|
||
}
|
||
}));
|
||
}
|
||
|
||
// Endpoints that answer only for a specific record get an input rather than
|
||
// an empty panel: they require a parameter, so eagerly calling them would
|
||
// just render an error.
|
||
function renderLookupTools(host, tools) {
|
||
host.innerHTML = tools.map((tool, i) => `
|
||
<div class="card">
|
||
<div class="card-header"><h3 class="card-title">${esc(tool.title)}</h3></div>
|
||
<div class="api-base-row">
|
||
<input type="text" class="form-input" data-lookup-input="${i}" placeholder="${esc(tool.placeholder)}">
|
||
<button class="btn btn-secondary btn-sm" data-lookup-run="${i}"><i class="ph ph-magnifying-glass"></i> <span>Look up</span></button>
|
||
</div>
|
||
<div class="panel-body" data-lookup-body="${i}">
|
||
<div class="table-msg">Enter a value above to query this endpoint.</div>
|
||
</div>
|
||
</div>`).join('');
|
||
|
||
host.querySelectorAll('[data-lookup-run]').forEach((btn) => {
|
||
const index = Number(btn.dataset.lookupRun);
|
||
const tool = tools[index];
|
||
const input = host.querySelector(`[data-lookup-input="${index}"]`);
|
||
const body = host.querySelector(`[data-lookup-body="${index}"]`);
|
||
|
||
const run = async () => {
|
||
const value = input.value.trim();
|
||
if (!value) return;
|
||
body.innerHTML = '<div class="table-msg">Querying…</div>';
|
||
try {
|
||
const payload = await api(tool.path, { params: { [tool.field]: value } });
|
||
renderPayload(body, payload);
|
||
} catch (err) {
|
||
if (handleApiError(err, 'lookup')) return;
|
||
body.innerHTML = `<div class="table-msg is-error">${esc(err.message)}</div>`;
|
||
}
|
||
};
|
||
|
||
btn.addEventListener('click', run);
|
||
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') run(); });
|
||
});
|
||
}
|
||
|
||
|
||
// ── Growth ───────────────────────────────────────────────────────────────
|
||
// growth.php returns one row per day that had signups — days with none are
|
||
// simply absent. Plotting those rows directly would compress gaps and make
|
||
// a quiet week look like steady growth, so the series is expanded to a
|
||
// continuous 30-day axis with explicit zeros.
|
||
function densifyDays(rows, valueKey, days = 30) {
|
||
const byDate = new Map();
|
||
(rows || []).forEach((r) => byDate.set(String(r.date).slice(0, 10), Number(r[valueKey]) || 0));
|
||
|
||
const series = [];
|
||
const today = new Date();
|
||
for (let i = days - 1; i >= 0; i--) {
|
||
const d = new Date(today);
|
||
d.setDate(today.getDate() - i);
|
||
const iso = d.toISOString().slice(0, 10);
|
||
series.push({
|
||
label: `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}`,
|
||
value: byDate.get(iso) || 0,
|
||
});
|
||
}
|
||
return series;
|
||
}
|
||
|
||
const sumSeries = (series) => series.reduce((a, p) => a + p.value, 0);
|
||
|
||
async function renderGrowth(host) {
|
||
host.innerHTML = '<div class="card"><div class="table-msg">Loading growth…</div></div>';
|
||
|
||
let payload;
|
||
try {
|
||
payload = await api('/Admin/v2/analytics/growth.php');
|
||
} catch (err) {
|
||
if (handleApiError(err, 'growth')) return;
|
||
host.innerHTML = `<div class="card"><div class="table-msg is-error">${esc(err.message)}</div></div>`;
|
||
return;
|
||
}
|
||
|
||
const passengers = densifyDays(payload?.passenger_daily, 'new_passengers');
|
||
const drivers = densifyDays(payload?.driver_daily, 'new_drivers');
|
||
const totals = payload?.totals || {};
|
||
|
||
const joinedP = sumSeries(passengers);
|
||
const joinedD = sumSeries(drivers);
|
||
const activeDays = passengers.filter((p, i) => p.value || drivers[i].value).length;
|
||
|
||
host.innerHTML = `
|
||
<div class="stats-grid">
|
||
${growthTile(fmtInt(totals.passengers), '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')}
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h3 class="card-title">Signups per day <span class="card-sub">last 30 days</span></h3>
|
||
<div class="legend" style="margin:0;">
|
||
<span class="legend-item"><i style="background:#6366f1"></i>Passengers</span>
|
||
<span class="legend-item"><i style="background:#10b981"></i>Captains</span>
|
||
</div>
|
||
</div>
|
||
<div class="chart-container"><canvas id="growthChart"></canvas></div>
|
||
${activeDays === 0
|
||
? '<div class="table-msg">Nobody signed up in the last 30 days.</div>'
|
||
: `<div class="stamp" style="display:block;text-align:center;margin-top:0.5rem;">
|
||
${activeDays} of 30 days had at least one signup
|
||
</div>`}
|
||
</div>`;
|
||
|
||
drawMultiLine('growthChart', [
|
||
{ label: 'Passengers', color: '#6366f1', points: passengers },
|
||
{ label: 'Captains', color: '#10b981', points: drivers },
|
||
]);
|
||
}
|
||
|
||
function growthTile(value, label, icon, tone) {
|
||
return `
|
||
<div class="stat-card">
|
||
<div class="stat-header">
|
||
<div>
|
||
<div class="stat-value">${esc(value)}</div>
|
||
<div class="stat-title">${esc(label)}</div>
|
||
</div>
|
||
<div class="stat-icon ${tone}"><i class="ph ${icon}"></i></div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// ── Advanced analytics ───────────────────────────────────────────────────
|
||
async function renderAnalytics(host) {
|
||
host.innerHTML = `
|
||
<div class="card notice-card">
|
||
<i class="ph-fill ph-info"></i>
|
||
<span>Revenue is counted from completed rides only. <strong>Total</strong> is what passengers paid;
|
||
<strong>platform share</strong> is what remains after the captain's cut.</span>
|
||
</div>
|
||
<div class="card" id="anRevenue"><div class="table-msg">Loading revenue…</div></div>
|
||
<div class="card" id="anRanking"><div class="table-msg">Loading captain ranking…</div></div>`;
|
||
|
||
// Revenue
|
||
try {
|
||
const rev = await api('/Admin/v2/analytics/revenue.php');
|
||
const daily = rev?.daily || [];
|
||
const summary = rev?.summary || {};
|
||
const series = densifyDays(daily, 'total_revenue');
|
||
const rides = densifyDays(daily, 'total_rides');
|
||
|
||
$('anRevenue').innerHTML = `
|
||
<div class="card-header">
|
||
<h3 class="card-title">Revenue <span class="card-sub">last 30 days</span></h3>
|
||
</div>
|
||
<div class="kpi-tiles">
|
||
<div class="kpi-tile"><div class="kpi-tile-value">${summary.total_revenue_all == null ? '—' : fmtMoney(summary.total_revenue_all)}</div><div class="kpi-tile-label">Total collected</div></div>
|
||
<div class="kpi-tile"><div class="kpi-tile-value">${summary.total_profit_all == null ? '—' : fmtMoney(summary.total_profit_all)}</div><div class="kpi-tile-label">Platform share</div></div>
|
||
<div class="kpi-tile"><div class="kpi-tile-value">${summary.avg_ride_price == null ? '—' : fmtMoney(summary.avg_ride_price)}</div><div class="kpi-tile-label">Average fare</div></div>
|
||
<div class="kpi-tile"><div class="kpi-tile-value">${fmtInt(sumSeries(rides))}</div><div class="kpi-tile-label">Completed rides</div></div>
|
||
</div>
|
||
<div class="chart-container" style="height:220px;margin-top:1rem;"><canvas id="revenueTrend"></canvas></div>
|
||
${sumSeries(series) === 0 ? '<div class="table-msg">No completed rides carried a fare in this period.</div>' : ''}`;
|
||
|
||
drawMultiLine('revenueTrend', [{ label: 'Revenue', color: '#06b6d4', points: series }]);
|
||
} catch (err) {
|
||
if (handleApiError(err, 'analytics-revenue')) return;
|
||
$('anRevenue').innerHTML = `<div class="table-msg is-error">${esc(err.message)}</div>`;
|
||
}
|
||
|
||
// Captain ranking
|
||
try {
|
||
const ranking = await api('/Admin/v2/analytics/driver_ranking.php');
|
||
const body = $('anRanking');
|
||
body.innerHTML = '<div class="card-header"><h3 class="card-title">Captain ranking</h3></div><div class="panel-body"></div>';
|
||
renderPayload(body.querySelector('.panel-body'), ranking);
|
||
} catch (err) {
|
||
if (handleApiError(err, 'analytics-ranking')) return;
|
||
$('anRanking').innerHTML = `<div class="table-msg is-error">${esc(err.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
// ── Blacklist & removal ──────────────────────────────────────────────────
|
||
// Deletion here is a real DELETE against passengers/driver — the account and
|
||
// its login are gone. The console therefore demands the phone number be
|
||
// retyped, on top of the confirm dialog.
|
||
async function renderBlacklist(host) {
|
||
host.innerHTML = `
|
||
<div class="card notice-card notice-danger">
|
||
<i class="ph-fill ph-warning-octagon"></i>
|
||
<span><strong>Removal is permanent.</strong> The account row is deleted and the phone number is
|
||
added to the blacklist so it cannot register again. There is no undo — only lifting the block,
|
||
which does not restore the deleted account.</span>
|
||
</div>
|
||
|
||
<div class="card" id="blacklistCurrent"><div class="table-msg">Loading blacklist…</div></div>
|
||
|
||
<div class="card">
|
||
<div class="card-header"><h3 class="card-title">Remove & blacklist</h3></div>
|
||
<div class="tariff-grid">
|
||
<label class="tariff-field">
|
||
<span class="tariff-label">Account type</span>
|
||
<select class="select-input" id="rmType">
|
||
<option value="passenger">Passenger</option>
|
||
<option value="driver">Captain</option>
|
||
</select>
|
||
</label>
|
||
<label class="tariff-field">
|
||
<span class="tariff-label">Phone <em>required</em></span>
|
||
<input type="text" class="form-input" id="rmPhone" autocomplete="off">
|
||
</label>
|
||
<label class="tariff-field">
|
||
<span class="tariff-label">Account ID <em>required for captains</em></span>
|
||
<input type="text" class="form-input" id="rmId" autocomplete="off">
|
||
</label>
|
||
<label class="tariff-field">
|
||
<span class="tariff-label">Reason</span>
|
||
<input type="text" class="form-input" id="rmReason" placeholder="Recorded on the blacklist entry">
|
||
</label>
|
||
<label class="tariff-field">
|
||
<span class="tariff-label">Retype the phone to confirm <em>safety check</em></span>
|
||
<input type="text" class="form-input" id="rmConfirmPhone" autocomplete="off">
|
||
</label>
|
||
</div>
|
||
<div class="api-base-row" style="margin-top:1rem;">
|
||
<button class="btn btn-danger btn-sm" id="rmSubmit"><i class="ph ph-trash"></i> <span>Delete and blacklist</span></button>
|
||
<span class="stamp" id="rmStatus"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header"><h3 class="card-title">Lift a block</h3></div>
|
||
<p class="card-note">Removes the phone number from the blacklist so it can register again. It does
|
||
not restore a deleted account.</p>
|
||
<div class="tariff-grid">
|
||
<label class="tariff-field">
|
||
<span class="tariff-label">Account type</span>
|
||
<select class="select-input" id="ubType">
|
||
<option value="passenger">Passenger</option>
|
||
<option value="driver">Captain</option>
|
||
</select>
|
||
</label>
|
||
<label class="tariff-field">
|
||
<span class="tariff-label">Phone</span>
|
||
<input type="text" class="form-input" id="ubPhone" autocomplete="off">
|
||
</label>
|
||
</div>
|
||
<div class="api-base-row" style="margin-top:1rem;">
|
||
<button class="btn btn-secondary btn-sm" id="ubSubmit"><i class="ph ph-lock-open"></i> <span>Lift block</span></button>
|
||
<span class="stamp" id="ubStatus"></span>
|
||
</div>
|
||
</div>`;
|
||
|
||
$('rmSubmit').addEventListener('click', () => removeAndBlacklist(host));
|
||
$('ubSubmit').addEventListener('click', () => liftBlock(host));
|
||
loadBlacklist();
|
||
}
|
||
|
||
async function loadBlacklist() {
|
||
const panel = $('blacklistCurrent');
|
||
try {
|
||
const payload = await api('/Admin/v2/quality/blacklist_manager.php', {
|
||
params: { action_type: 'get_all' },
|
||
});
|
||
panel.innerHTML = '<div class="card-header"><h3 class="card-title">Currently blocked</h3></div><div class="panel-body"></div>';
|
||
renderPayload(panel.querySelector('.panel-body'), payload);
|
||
} catch (err) {
|
||
if (handleApiError(err, 'blacklist')) return;
|
||
panel.innerHTML = `<div class="card-header"><h3 class="card-title">Currently blocked</h3></div><div class="table-msg is-error">${esc(err.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
async function removeAndBlacklist(host) {
|
||
const type = $('rmType').value;
|
||
const phone = $('rmPhone').value.trim();
|
||
const id = $('rmId').value.trim();
|
||
const reason = $('rmReason').value.trim();
|
||
const confirmPhone = $('rmConfirmPhone').value.trim();
|
||
const status = $('rmStatus');
|
||
const label = type === 'driver' ? 'captain' : 'passenger';
|
||
|
||
if (!phone) {
|
||
toast('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 = `
|
||
<div class="card notice-card notice-danger">
|
||
<i class="ph-fill ph-warning"></i>
|
||
<span><strong>Launching creates a real discount code and notifies every passenger in the selected country.</strong>
|
||
The promo stays valid for seven days. Always preview first.</span>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header"><h3 class="card-title">Campaign parameters</h3></div>
|
||
<div class="tariff-grid">
|
||
<label class="tariff-field">
|
||
<span class="tariff-label">Country</span>
|
||
<select class="select-input" id="cmpCountry">
|
||
<option value="JO">Jordan</option>
|
||
<option value="SY">Syria</option>
|
||
<option value="EG">Egypt</option>
|
||
<option value="IQ">Iraq</option>
|
||
</select>
|
||
</label>
|
||
<label class="tariff-field">
|
||
<span class="tariff-label">Region <em>defaults to the capital</em></span>
|
||
<input type="text" class="form-input" id="cmpRegion" placeholder="Amman">
|
||
</label>
|
||
<label class="tariff-field">
|
||
<span class="tariff-label">Siro base price</span>
|
||
<input type="number" step="any" class="form-input" id="cmpBasePrice" value="1.25">
|
||
</label>
|
||
</div>
|
||
<div class="api-base-row" style="margin-top:1rem;">
|
||
<button class="btn btn-secondary btn-sm" id="cmpPreview"><i class="ph ph-eye"></i> <span>Preview</span></button>
|
||
<button class="btn btn-primary btn-sm" id="cmpLaunch" disabled><i class="ph ph-rocket-launch"></i> <span>Launch campaign</span></button>
|
||
<span class="stamp" id="cmpStatus">Preview first to enable launching.</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" id="cmpResult">
|
||
<div class="table-msg">No analysis run yet.</div>
|
||
</div>
|
||
|
||
<div class="card" id="cmpLog"><div class="table-msg">Loading campaign history…</div></div>`;
|
||
|
||
$('cmpPreview').addEventListener('click', () => runCampaign(true));
|
||
$('cmpLaunch').addEventListener('click', () => runCampaign(false));
|
||
loadCampaignLog();
|
||
}
|
||
|
||
// A launch is only allowed for parameters that were previewed, so an edit
|
||
// after previewing disarms the button again.
|
||
let previewedCampaign = null;
|
||
|
||
function campaignParams() {
|
||
return {
|
||
country_code: $('cmpCountry').value,
|
||
region_name: $('cmpRegion').value.trim(),
|
||
siro_base_price: $('cmpBasePrice').value.trim(),
|
||
};
|
||
}
|
||
|
||
async function runCampaign(isPreview) {
|
||
const params = campaignParams();
|
||
const signature = JSON.stringify(params);
|
||
|
||
if (!isPreview) {
|
||
if (signature !== previewedCampaign) {
|
||
toast('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 = '<div class="table-msg">Running market analysis…</div>';
|
||
|
||
try {
|
||
const payload = await api('/Admin/marketing/trigger_campaign.php', {
|
||
params: isPreview ? { ...params, dry_run: '1' } : params,
|
||
});
|
||
|
||
$('cmpResult').innerHTML = `<div class="card-header"><h3 class="card-title">${isPreview ? 'Preview' : 'Launch result'}</h3></div><div class="panel-body"></div>`;
|
||
renderPayload($('cmpResult').querySelector('.panel-body'), payload);
|
||
|
||
if (isPreview) {
|
||
const opportunity = payload?.ai_analysis?.opportunity_detected ?? payload?.campaign_created;
|
||
previewedCampaign = signature;
|
||
$('cmpLaunch').disabled = false;
|
||
$('cmpStatus').textContent = opportunity === false
|
||
? '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 = `<div class="table-msg is-error">${esc(err.message)}</div>`;
|
||
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 = '<div class="card-header"><h3 class="card-title">Campaign history</h3></div><div class="panel-body"></div>';
|
||
renderPayload(panel.querySelector('.panel-body'), payload);
|
||
} catch (err) {
|
||
if (handleApiError(err, 'campaign-log')) return;
|
||
panel.innerHTML = `<div class="card-header"><h3 class="card-title">Campaign history</h3></div><div class="table-msg is-error">${esc(err.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
// ── App versions ─────────────────────────────────────────────────────────
|
||
async function renderAppVersions(host) {
|
||
host.innerHTML = '<div class="card"><div class="table-msg">Loading package versions…</div></div>';
|
||
|
||
let packages = [];
|
||
try {
|
||
packages = normaliseRows(await api('/serviceapp/getPackages.php'));
|
||
} catch (err) {
|
||
if (handleApiError(err, 'packages')) return;
|
||
host.innerHTML = `<div class="card"><div class="table-msg is-error">${esc(err.message)}</div></div>`;
|
||
return;
|
||
}
|
||
|
||
if (!packages.length) {
|
||
host.innerHTML = '<div class="card"><div class="table-msg">No package rows configured.</div></div>';
|
||
return;
|
||
}
|
||
|
||
host.innerHTML = `
|
||
<div class="card notice-card">
|
||
<i class="ph-fill ph-info"></i>
|
||
<span>Each app compares its own build against this number on launch. Raising it can force every
|
||
user of that app to update before they can continue.</span>
|
||
</div>
|
||
${packages.map((pkg, index) => `
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h3 class="card-title">
|
||
${esc(pkg.name || pkg.packageName || pkg.app_name || `Package #${pkg.id}`)}
|
||
<span class="card-sub">row #${esc(pkg.id)}</span>
|
||
</h3>
|
||
<button class="btn btn-primary btn-sm" data-pkg-save="${index}"><i class="ph ph-floppy-disk"></i> <span>Update version</span></button>
|
||
</div>
|
||
<div class="tariff-grid">
|
||
<label class="tariff-field">
|
||
<span class="tariff-label">Current version</span>
|
||
<input type="text" class="form-input" data-pkg-version="${index}" value="${esc(pkg.version ?? '')}">
|
||
</label>
|
||
</div>
|
||
</div>`).join('')}`;
|
||
|
||
host.querySelectorAll('[data-pkg-save]').forEach((btn) =>
|
||
btn.addEventListener('click', () => saveVersion(packages[Number(btn.dataset.pkgSave)], Number(btn.dataset.pkgSave), host)));
|
||
}
|
||
|
||
async function saveVersion(pkg, index, host) {
|
||
const input = host.querySelector(`[data-pkg-version="${index}"]`);
|
||
const version = input.value.trim();
|
||
|
||
if (version === String(pkg.version ?? '')) {
|
||
toast('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 = '<div class="card"><div class="table-msg">Loading captains awaiting review…</div></div>';
|
||
|
||
let drivers = [];
|
||
try {
|
||
const payload = await api('/auth/driver/drivers_pending_list.php', {
|
||
params: { limit: DOCS_PAGE_SIZE, offset },
|
||
});
|
||
drivers = normaliseRows(payload);
|
||
} catch (err) {
|
||
if (handleApiError(err, 'driver-docs')) return;
|
||
host.innerHTML = `<div class="card"><div class="table-msg is-error">${esc(err.message)}</div></div>`;
|
||
return;
|
||
}
|
||
|
||
if (!drivers.length && offset === 0) {
|
||
host.innerHTML = '<div class="card"><div class="table-msg">No captains are awaiting document review.</div></div>';
|
||
return;
|
||
}
|
||
|
||
host.innerHTML = `
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h3 class="card-title">Awaiting review <span class="card-sub">showing ${drivers.length} from #${offset + 1}</span></h3>
|
||
<div style="display:flex; gap:0.5rem;">
|
||
<button class="btn btn-secondary btn-sm" id="docsPrev" ${offset === 0 ? 'disabled' : ''}><i class="ph ph-caret-left"></i></button>
|
||
<button class="btn btn-secondary btn-sm" id="docsNext" ${drivers.length < DOCS_PAGE_SIZE ? 'disabled' : ''}><i class="ph ph-caret-right"></i></button>
|
||
</div>
|
||
</div>
|
||
<div class="table-responsive">
|
||
<table class="data-table">
|
||
<thead><tr><th>ID</th><th>Name</th><th>Phone</th><th></th></tr></thead>
|
||
<tbody>
|
||
${drivers.map((d) => `
|
||
<tr>
|
||
<td><strong>#${esc(d.id)}</strong></td>
|
||
<td>${esc(`${d.first_name || ''} ${d.last_name || ''}`.trim() || 'Unnamed')}</td>
|
||
<td>${esc(maskPhone(d.phone))}</td>
|
||
<td><button class="btn btn-secondary btn-sm" data-review="${esc(d.id)}"><i class="ph ph-files"></i> Review documents</button></td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<div class="card" id="docsDetail">
|
||
<div class="table-msg">Pick a captain above to inspect their documents.</div>
|
||
</div>`;
|
||
|
||
$('docsPrev')?.addEventListener('click', () =>
|
||
renderDriverDocs(host, Math.max(0, offset - DOCS_PAGE_SIZE)));
|
||
$('docsNext')?.addEventListener('click', () =>
|
||
renderDriverDocs(host, offset + DOCS_PAGE_SIZE));
|
||
|
||
host.querySelectorAll('[data-review]').forEach((btn) =>
|
||
btn.addEventListener('click', () => showDriverDocs(btn.dataset.review, host)));
|
||
}
|
||
|
||
async function showDriverDocs(driverId, host) {
|
||
const panel = $('docsDetail');
|
||
panel.innerHTML = '<div class="table-msg">Loading documents…</div>';
|
||
|
||
let driver = {};
|
||
let documents = [];
|
||
try {
|
||
// Sent as a POST body: filterRequest() ignores query strings, so the
|
||
// mobile app's GET "?id=" form never reaches this endpoint's $driverId.
|
||
const payload = await api('/auth/driver/driver_details.php', { params: { id: driverId } });
|
||
driver = payload?.driver || {};
|
||
documents = payload?.documents || [];
|
||
} catch (err) {
|
||
if (handleApiError(err, 'driver-details')) return;
|
||
panel.innerHTML = `<div class="table-msg is-error">${esc(err.message)}</div>`;
|
||
return;
|
||
}
|
||
|
||
const facts = Object.entries(driver)
|
||
.filter(([k, v]) => !/token|password|fingerprint/i.test(k) && v !== null && v !== '')
|
||
.slice(0, 18);
|
||
|
||
panel.innerHTML = `
|
||
<div class="card-header">
|
||
<h3 class="card-title">
|
||
${esc(`${driver.first_name || ''} ${driver.last_name || ''}`.trim() || `Captain #${driverId}`)}
|
||
<span class="card-sub">#${esc(driverId)} · ${esc(driver.status || 'unknown')}</span>
|
||
</h3>
|
||
<button class="btn btn-primary btn-sm" data-approve-driver="${esc(driverId)}">
|
||
<i class="ph ph-check"></i> <span>Approve & activate</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="sub-panel">
|
||
<h4 class="sub-panel-title">Documents (${documents.length})</h4>
|
||
${documents.length ? `
|
||
<div class="doc-grid">
|
||
${documents.map((doc) => `
|
||
<figure class="doc-card">
|
||
${doc.link
|
||
? `<a href="${esc(doc.link)}" target="_blank" rel="noopener">
|
||
<img src="${esc(doc.link)}" alt="${esc(doc.doc_type || 'document')}" loading="lazy">
|
||
</a>`
|
||
: '<div class="doc-missing"><i class="ph ph-file-x"></i> no file linked</div>'}
|
||
<figcaption>
|
||
<strong>${esc(humanize(doc.doc_type || 'document'))}</strong>
|
||
<span class="stamp">${esc(doc.image_name || '—')}</span>
|
||
</figcaption>
|
||
</figure>`).join('')}
|
||
</div>`
|
||
: '<div class="table-msg">This captain has uploaded no documents — approving now would activate an unverified account.</div>'}
|
||
</div>
|
||
|
||
<div class="sub-panel">
|
||
<h4 class="sub-panel-title">Record</h4>
|
||
<div class="mini-list">
|
||
${facts.map(([k, v]) => `
|
||
<div class="kv-row">
|
||
<span>${esc(humanize(k))}</span>
|
||
<strong>${esc(/phone/i.test(k) ? maskPhone(v) : formatValue(v, k))}</strong>
|
||
</div>`).join('')}
|
||
</div>
|
||
</div>`;
|
||
|
||
panel.querySelector('[data-approve-driver]').addEventListener('click', () =>
|
||
approveDriver(driverId, driver, documents.length, host));
|
||
}
|
||
|
||
async function approveDriver(driverId, driver, docCount, host) {
|
||
const name = `${driver.first_name || ''} ${driver.last_name || ''}`.trim() || `#${driverId}`;
|
||
const warning = docCount === 0
|
||
? '\n\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 = `
|
||
<div class="card" id="staffPending"><div class="table-msg">Loading pending accounts…</div></div>
|
||
<div class="card" id="staffList"><div class="table-msg">Loading employees…</div></div>
|
||
<div class="card">
|
||
<div class="card-header"><h3 class="card-title">Add a staff account</h3></div>
|
||
<p class="card-note">
|
||
Creates a login for the Siro admin tools. Choose the password with the new member present, or have
|
||
them change it at first sign-in — it is stored hashed and cannot be read back.
|
||
</p>
|
||
<div class="tariff-grid">
|
||
<label class="tariff-field">
|
||
<span class="tariff-label">Role</span>
|
||
<select class="select-input" id="staffRole">
|
||
<option value="service">Customer service</option>
|
||
<option value="admin">Administrator</option>
|
||
</select>
|
||
</label>
|
||
<label class="tariff-field">
|
||
<span class="tariff-label">Full name <em>required</em></span>
|
||
<input type="text" class="form-input" id="staffName" autocomplete="off">
|
||
</label>
|
||
<label class="tariff-field">
|
||
<span class="tariff-label">Phone</span>
|
||
<input type="text" class="form-input" id="staffPhone" autocomplete="off">
|
||
</label>
|
||
<label class="tariff-field">
|
||
<span class="tariff-label">Email</span>
|
||
<input type="email" class="form-input" id="staffEmail" autocomplete="off">
|
||
</label>
|
||
<label class="tariff-field">
|
||
<span class="tariff-label">Password <em>required</em></span>
|
||
<input type="password" class="form-input" id="staffPassword" autocomplete="new-password">
|
||
</label>
|
||
<label class="tariff-field">
|
||
<span class="tariff-label">Country</span>
|
||
<input type="text" class="form-input" id="staffCountry" value="Jordan">
|
||
</label>
|
||
</div>
|
||
<div class="api-base-row" style="margin-top:1rem;">
|
||
<button class="btn btn-primary btn-sm" id="staffAdd"><i class="ph ph-user-plus"></i> <span>Create account</span></button>
|
||
<span class="stamp" id="staffStatus"></span>
|
||
</div>
|
||
</div>`;
|
||
|
||
$('staffAdd').addEventListener('click', () => addStaff(host));
|
||
loadStaffPending(host);
|
||
loadEmployees();
|
||
}
|
||
|
||
async function loadStaffPending(host) {
|
||
const panel = $('staffPending');
|
||
try {
|
||
const payload = await api('/Admin/Staff/pending.php');
|
||
const rows = payload?.data || [];
|
||
const sources = payload?.sources || {};
|
||
|
||
const notes = Object.entries(sources)
|
||
.filter(([, state]) => state !== 'ok')
|
||
.map(([name, state]) => `<div class="table-msg is-error">${esc(humanize(name))}: ${esc(state)}</div>`)
|
||
.join('');
|
||
|
||
panel.innerHTML = `
|
||
<div class="card-header"><h3 class="card-title">Pending activation</h3></div>
|
||
${notes}
|
||
${rows.length ? `
|
||
<div class="table-responsive">
|
||
<table class="data-table">
|
||
<thead><tr><th>ID</th><th>Name</th><th>Phone</th><th>Type</th><th>Requested</th><th></th></tr></thead>
|
||
<tbody>
|
||
${rows.map((r) => `
|
||
<tr>
|
||
<td><strong>#${esc(r.id)}</strong></td>
|
||
<td>${esc(r.name || '—')}</td>
|
||
<td>${esc(maskPhone(r.phone))}</td>
|
||
<td><span class="badge badge-info">${esc(r.type)}</span></td>
|
||
<td>${esc(fmtDate(r.created_at, true))}</td>
|
||
<td><button class="btn btn-secondary btn-sm" data-activate="${esc(r.id)}" data-type="${esc(r.type)}">
|
||
<i class="ph ph-check"></i> Activate
|
||
</button></td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>` : (notes ? '' : '<div class="table-msg">No accounts are waiting for activation.</div>')}`;
|
||
|
||
panel.querySelectorAll('[data-activate]').forEach((btn) =>
|
||
btn.addEventListener('click', () => activateStaff(btn.dataset.activate, btn.dataset.type, host)));
|
||
} catch (err) {
|
||
if (handleApiError(err, 'staff-pending')) return;
|
||
panel.innerHTML = `<div class="table-msg is-error">${esc(err.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
async function loadEmployees() {
|
||
const panel = $('staffList');
|
||
try {
|
||
const payload = await api('/Admin/employee/get.php');
|
||
panel.innerHTML = '<div class="card-header"><h3 class="card-title">Employees</h3></div><div class="panel-body"></div>';
|
||
renderPayload(panel.querySelector('.panel-body'), payload);
|
||
} catch (err) {
|
||
if (handleApiError(err, 'employees')) return;
|
||
panel.innerHTML = `<div class="card-header"><h3 class="card-title">Employees</h3></div><div class="table-msg is-error">${esc(err.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
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 = '<div class="card"><div class="table-msg">Loading draft routes…</div></div>';
|
||
|
||
let routes = [];
|
||
try {
|
||
const payload = await api('/Admin/transit/route/pending.php');
|
||
routes = payload?.routes || [];
|
||
} catch (err) {
|
||
if (handleApiError(err, 'routes')) return;
|
||
host.innerHTML = `<div class="card"><div class="table-msg is-error">${esc(err.message)}</div></div>`;
|
||
return;
|
||
}
|
||
|
||
if (!routes.length) {
|
||
host.innerHTML = '<div class="card"><div class="table-msg">No routes are waiting for approval.</div></div>';
|
||
return;
|
||
}
|
||
|
||
host.innerHTML = routes.map((route, index) => `
|
||
<div class="card" data-route-card="${index}">
|
||
<div class="card-header">
|
||
<h3 class="card-title">
|
||
${esc(route.name_ar || route.name_en || 'Unnamed route')}
|
||
<span class="card-sub">#${esc(route.id)} · ${esc(route.org_name || 'unknown organisation')}</span>
|
||
</h3>
|
||
<div style="display:flex; gap:0.5rem;">
|
||
<button class="btn btn-secondary btn-sm" data-route-action="reject" data-route="${index}"><i class="ph ph-x"></i> Reject</button>
|
||
<button class="btn btn-primary btn-sm" data-route-action="approve" data-route="${index}"><i class="ph ph-check"></i> <span>Approve</span></button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="kpi-tiles">
|
||
<div class="kpi-tile"><div class="kpi-tile-value">${esc(route.direction || '—')}</div><div class="kpi-tile-label">Direction</div></div>
|
||
<div class="kpi-tile"><div class="kpi-tile-value">${fmtNum(route.distance_km)} km</div><div class="kpi-tile-label">Distance</div></div>
|
||
<div class="kpi-tile"><div class="kpi-tile-value">${fmtInt(route.duration_min)} min</div><div class="kpi-tile-label">Duration</div></div>
|
||
<div class="kpi-tile"><div class="kpi-tile-value">${fmtInt(route.stops_count)}</div><div class="kpi-tile-label">Stops</div></div>
|
||
<div class="kpi-tile"><div class="kpi-tile-value">${esc(route.country || '—')}</div><div class="kpi-tile-label">Country</div></div>
|
||
<div class="kpi-tile"><div class="kpi-tile-value">${esc(fmtDate(route.created_at, true))}</div><div class="kpi-tile-label">Submitted</div></div>
|
||
</div>
|
||
|
||
<div class="sub-panel">
|
||
<h4 class="sub-panel-title">Stops</h4>
|
||
${(route.stops || []).length ? `
|
||
<ol class="stop-list">
|
||
${route.stops.map((s) => `
|
||
<li>
|
||
<span>${esc(s.name_ar || 'Unnamed stop')}</span>
|
||
${Number(s.is_major) ? '<span class="badge badge-primary">major</span>' : ''}
|
||
<span class="stamp">${esc(shortCoord(`${s.latitude},${s.longitude}`))}</span>
|
||
</li>`).join('')}
|
||
</ol>` : '<div class="table-msg">This route has no stops recorded.</div>'}
|
||
</div>
|
||
</div>`).join('');
|
||
|
||
host.querySelectorAll('[data-route-action]').forEach((btn) =>
|
||
btn.addEventListener('click', () =>
|
||
decideRoute(routes[Number(btn.dataset.route)], btn.dataset.routeAction, host)));
|
||
}
|
||
|
||
async function decideRoute(route, action, host) {
|
||
const verb = action === 'approve' ? 'approve' : 'reject';
|
||
const consequence = action === 'approve'
|
||
? '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 = `
|
||
<div class="card notice-card notice-danger">
|
||
<i class="ph-fill ph-warning"></i>
|
||
<span><strong>This reaches every device at once and cannot be recalled.</strong>
|
||
The message is recorded in the audit log against your account.</span>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header"><h3 class="card-title">Compose</h3></div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label" for="bcAudience">Audience</label>
|
||
<select class="select-input" id="bcAudience" style="width:100%;">
|
||
<option value="drivers">All captains</option>
|
||
<option value="passengers">All passengers</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label" for="bcTitle">Title <span class="stamp">max 120</span></label>
|
||
<input type="text" class="form-input" id="bcTitle" maxlength="120" placeholder="Notification title" style="padding-left:1rem;">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label" for="bcBody">Message <span class="stamp">max 1000</span></label>
|
||
<textarea class="form-input decrypt-area" id="bcBody" rows="4" maxlength="1000" placeholder="Message text"></textarea>
|
||
</div>
|
||
|
||
<div class="api-base-row">
|
||
<button class="btn btn-primary btn-sm" id="bcSend"><i class="ph ph-paper-plane-tilt"></i> <span>Review & send</span></button>
|
||
<span class="stamp" id="bcStatus"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header"><h3 class="card-title">Preview</h3></div>
|
||
<div class="push-preview">
|
||
<div class="push-app"><i class="ph-fill ph-car"></i> Siro</div>
|
||
<div class="push-title" id="bcPreviewTitle">Notification title</div>
|
||
<div class="push-body" id="bcPreviewBody">Message text</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
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 = '<div class="card"><div class="table-msg">Loading tariff…</div></div>';
|
||
|
||
try {
|
||
const payload = await api('/ride/kazan/get.php');
|
||
tariffRows = Array.isArray(payload) ? payload : normaliseRows(payload);
|
||
} catch (err) {
|
||
if (handleApiError(err, 'tariff')) return;
|
||
host.innerHTML = `<div class="card"><div class="table-msg is-error">${esc(err.message)}</div></div>`;
|
||
return;
|
||
}
|
||
|
||
if (!tariffRows.length) {
|
||
host.innerHTML = '<div class="card"><div class="table-msg">No tariff rows configured.</div></div>';
|
||
return;
|
||
}
|
||
|
||
const readOnly = !isSuperAdmin();
|
||
host.innerHTML = `
|
||
${readOnly ? `
|
||
<div class="card notice-card">
|
||
<i class="ph-fill ph-info"></i>
|
||
<span>You are signed in as an admin, so the tariff is shown read-only. Only a super admin can change prices.</span>
|
||
</div>` : `
|
||
<div class="card notice-card notice-danger">
|
||
<i class="ph-fill ph-warning"></i>
|
||
<span><strong>These values are live.</strong> Saving changes what every passenger is charged from the next ride onwards. Changes are recorded in the audit log against your account.</span>
|
||
</div>`}
|
||
${tariffRows.map((row, index) => tariffCard(row, index, readOnly)).join('')}`;
|
||
|
||
if (readOnly) return;
|
||
|
||
host.querySelectorAll('[data-tariff-save]').forEach((btn) =>
|
||
btn.addEventListener('click', () => saveTariff(Number(btn.dataset.tariffSave), host)));
|
||
host.querySelectorAll('[data-tariff-reset]').forEach((btn) =>
|
||
btn.addEventListener('click', () => renderTariffEditor(host)));
|
||
}
|
||
|
||
function tariffCard(row, index, readOnly) {
|
||
const fields = TARIFF_FIELDS.filter((f) => row[f.key] !== undefined);
|
||
return `
|
||
<div class="card" data-tariff-card="${index}">
|
||
<div class="card-header">
|
||
<h3 class="card-title">
|
||
${esc(row.country || 'Tariff')} <span class="card-sub">row #${esc(row.id)}</span>
|
||
</h3>
|
||
${readOnly ? '' : `
|
||
<div style="display:flex; gap:0.5rem;">
|
||
<button class="btn btn-secondary btn-sm" data-tariff-reset="${index}"><i class="ph ph-arrow-counter-clockwise"></i> Reset</button>
|
||
<button class="btn btn-primary btn-sm" data-tariff-save="${index}"><i class="ph ph-floppy-disk"></i> <span>Review & save</span></button>
|
||
</div>`}
|
||
</div>
|
||
<div class="tariff-grid">
|
||
${fields.map((f) => `
|
||
<label class="tariff-field">
|
||
<span class="tariff-label">${esc(f.label)}${f.hint ? ` <em>${esc(f.hint)}</em>` : ''}</span>
|
||
<input class="form-input" type="${f.type === 'text' ? 'text' : 'number'}" step="any"
|
||
data-tariff-input="${index}" data-field="${esc(f.key)}"
|
||
value="${esc(row[f.key] ?? '')}" ${readOnly ? 'disabled' : ''}>
|
||
</label>`).join('')}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
async function saveTariff(index, host) {
|
||
if (!isSuperAdmin()) {
|
||
toast('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]) => `
|
||
<div class="kpi-tile">
|
||
<div class="kpi-tile-value">${esc(/status|state/i.test(k) ? labelStatus(v) : formatValue(v, k))}</div>
|
||
<div class="kpi-tile-label">${esc(humanize(k))}</div>
|
||
</div>`).join('');
|
||
frag.appendChild(grid);
|
||
}
|
||
|
||
nested.forEach(([key, value]) => {
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'sub-panel';
|
||
const heading = document.createElement('h4');
|
||
heading.className = 'sub-panel-title';
|
||
heading.textContent = humanize(key);
|
||
wrap.appendChild(heading);
|
||
wrap.appendChild(buildNode(value, depth + 1));
|
||
frag.appendChild(wrap);
|
||
});
|
||
|
||
return frag;
|
||
}
|
||
|
||
function buildTable(rows) {
|
||
const objects = rows.every((r) => r && typeof r === 'object' && !Array.isArray(r));
|
||
if (!objects) {
|
||
const list = document.createElement('div');
|
||
list.className = 'mini-list';
|
||
list.innerHTML = rows.map((r) => `<div class="kv-row"><span>${esc(String(r))}</span></div>`).join('');
|
||
return list;
|
||
}
|
||
|
||
const columns = [...new Set(rows.flatMap((r) => Object.keys(r)))]
|
||
.filter((c) => !/token|password|fingerprint/i.test(c))
|
||
.slice(0, 12);
|
||
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'table-responsive';
|
||
wrap.innerHTML = `
|
||
<table class="data-table">
|
||
<thead><tr>${columns.map((c) => `<th>${esc(humanize(c))}</th>`).join('')}</tr></thead>
|
||
<tbody>
|
||
${rows.slice(0, 100).map((row) => `<tr>${columns.map((c) => `<td>${cellHtml(c, row[c])}</td>`).join('')}</tr>`).join('')}
|
||
</tbody>
|
||
</table>`;
|
||
return wrap;
|
||
}
|
||
|
||
function cellHtml(column, value) {
|
||
if (value === null || value === undefined || value === '') return '<span class="stamp">—</span>';
|
||
if (typeof value === 'object') return `<span class="stamp">${esc(JSON.stringify(value).slice(0, 60))}</span>`;
|
||
if (/status|type|state|result/i.test(column)) {
|
||
return `<span class="badge ${badgeClass(value)}">${esc(labelStatus(value))}</span>`;
|
||
}
|
||
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) => `<option value="${esc(c.value)}">${esc(c.label)}</option>`)
|
||
.join('') + '<option value="__custom__">Custom…</option>';
|
||
|
||
const known = API_CANDIDATES.some((c) => c.value === API_BASE);
|
||
el.apiBaseSelect.value = known ? API_BASE : '__custom__';
|
||
el.apiBaseCustom.value = known ? '' : API_BASE;
|
||
el.apiBaseCustom.hidden = known;
|
||
|
||
el.apiBaseSelect.addEventListener('change', () => {
|
||
el.apiBaseCustom.hidden = el.apiBaseSelect.value !== '__custom__';
|
||
});
|
||
|
||
el.saveApiBaseBtn.addEventListener('click', () => {
|
||
const chosen = el.apiBaseSelect.value === '__custom__'
|
||
? el.apiBaseCustom.value.trim().replace(/\/$/, '')
|
||
: el.apiBaseSelect.value;
|
||
if (!chosen) return;
|
||
API_BASE = chosen;
|
||
localStorage.setItem(API_BASE_KEY, chosen);
|
||
toast(`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 = `
|
||
<div class="modal-head">
|
||
<h3>Trip #${esc(r.id)}</h3>
|
||
<button class="btn-icon" onclick="closeModal()"><i class="ph ph-x"></i></button>
|
||
</div>
|
||
<div class="mini-list">
|
||
${rows.map(([k, v]) => `<div class="kv-row"><span>${k}</span><strong>${esc(String(v ?? '—') || '—')}</strong></div>`).join('')}
|
||
</div>`;
|
||
$('detailsModal').classList.add('active');
|
||
}
|
||
|
||
window.closeModal = () => $('detailsModal')?.classList.remove('active');
|
||
|
||
// ── Charts (dependency-free canvas rendering) ────────────────────────────
|
||
const chartData = new Map();
|
||
|
||
function prepareCanvas(id) {
|
||
const canvas = $(id);
|
||
if (!canvas || !canvas.parentElement) return null;
|
||
const ratio = window.devicePixelRatio || 1;
|
||
// Collapse the canvas first: a sized canvas props its own container open,
|
||
// so measuring before resetting would make charts grow but never shrink.
|
||
canvas.width = 0;
|
||
canvas.height = 0;
|
||
const w = canvas.parentElement.clientWidth;
|
||
const h = canvas.parentElement.clientHeight;
|
||
canvas.width = w * ratio;
|
||
canvas.height = h * ratio;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.scale(ratio, ratio);
|
||
ctx.clearRect(0, 0, w, h);
|
||
return { ctx, w, h };
|
||
}
|
||
|
||
function drawLineChart(id, series) {
|
||
chartData.set(id, { type: 'line', series });
|
||
const c = prepareCanvas(id);
|
||
if (!c || !series.length) return;
|
||
const { ctx, w, h } = c;
|
||
const padX = 34, padTop = 16, padBottom = 26;
|
||
const max = Math.max(...series.map((s) => s.value), 1);
|
||
const stepX = series.length > 1 ? (w - padX * 2) / (series.length - 1) : 0;
|
||
const y = (v) => h - padBottom - (v / max) * (h - padTop - padBottom);
|
||
const pts = series.map((s, i) => ({ x: padX + i * stepX, y: y(s.value) }));
|
||
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
|
||
ctx.fillStyle = '#64748b';
|
||
ctx.font = '10px Inter, sans-serif';
|
||
for (let i = 0; i <= 4; i++) {
|
||
const gy = padTop + i * (h - padTop - padBottom) / 4;
|
||
ctx.beginPath(); ctx.moveTo(padX, gy); ctx.lineTo(w - padX + 10, gy); ctx.stroke();
|
||
ctx.textAlign = 'right';
|
||
ctx.fillText(String(Math.round(max - i * max / 4)), padX - 6, gy + 3);
|
||
}
|
||
|
||
const grad = ctx.createLinearGradient(0, padTop, 0, h - padBottom);
|
||
grad.addColorStop(0, 'rgba(99,102,241,0.35)');
|
||
grad.addColorStop(1, 'rgba(99,102,241,0)');
|
||
ctx.beginPath();
|
||
pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)));
|
||
ctx.lineTo(pts[pts.length - 1].x, h - padBottom);
|
||
ctx.lineTo(pts[0].x, h - padBottom);
|
||
ctx.closePath();
|
||
ctx.fillStyle = grad;
|
||
ctx.fill();
|
||
|
||
ctx.beginPath();
|
||
pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)));
|
||
ctx.strokeStyle = '#6366f1';
|
||
ctx.lineWidth = 2.5;
|
||
ctx.lineJoin = 'round';
|
||
ctx.stroke();
|
||
|
||
const last = pts[pts.length - 1];
|
||
ctx.beginPath();
|
||
ctx.arc(last.x, last.y, 4.5, 0, Math.PI * 2);
|
||
ctx.fillStyle = '#6366f1';
|
||
ctx.fill();
|
||
ctx.strokeStyle = '#fff';
|
||
ctx.lineWidth = 2;
|
||
ctx.stroke();
|
||
|
||
ctx.fillStyle = '#64748b';
|
||
ctx.textAlign = 'center';
|
||
const every = Math.ceil(series.length / 7);
|
||
series.forEach((s, i) => {
|
||
if (i % every === 0 || i === series.length - 1) ctx.fillText(s.label, pts[i].x, h - 8);
|
||
});
|
||
}
|
||
|
||
|
||
// Multiple series on one axis, sharing a scale so the lines are comparable.
|
||
function drawMultiLine(id, seriesList) {
|
||
chartData.set(id, { type: 'multi', series: seriesList });
|
||
const c = prepareCanvas(id);
|
||
if (!c || !seriesList.length || !seriesList[0].points.length) return;
|
||
const { ctx, w, h } = c;
|
||
|
||
const padL = 38, padR = 14, padTop = 14, padBottom = 26;
|
||
const count = seriesList[0].points.length;
|
||
const max = Math.max(1, ...seriesList.flatMap((s) => s.points.map((p) => p.value)));
|
||
const stepX = count > 1 ? (w - padL - padR) / (count - 1) : 0;
|
||
const y = (v) => h - padBottom - (v / max) * (h - padTop - padBottom);
|
||
|
||
// grid + scale
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
|
||
ctx.fillStyle = '#64748b';
|
||
ctx.font = '10px Inter, sans-serif';
|
||
const ticks = Math.min(4, max);
|
||
for (let i = 0; i <= ticks; i++) {
|
||
const gy = padTop + i * (h - padTop - padBottom) / ticks;
|
||
ctx.beginPath(); ctx.moveTo(padL, gy); ctx.lineTo(w - padR, gy); ctx.stroke();
|
||
ctx.textAlign = 'right';
|
||
ctx.fillText(String(Math.round(max - i * max / ticks)), padL - 6, gy + 3);
|
||
}
|
||
|
||
seriesList.forEach((serie) => {
|
||
const pts = serie.points.map((p, i) => ({ x: padL + i * stepX, y: y(p.value) }));
|
||
|
||
const grad = ctx.createLinearGradient(0, padTop, 0, h - padBottom);
|
||
grad.addColorStop(0, serie.color + '55');
|
||
grad.addColorStop(1, serie.color + '00');
|
||
ctx.beginPath();
|
||
pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)));
|
||
ctx.lineTo(pts[pts.length - 1].x, h - padBottom);
|
||
ctx.lineTo(pts[0].x, h - padBottom);
|
||
ctx.closePath();
|
||
ctx.fillStyle = grad;
|
||
ctx.fill();
|
||
|
||
ctx.beginPath();
|
||
pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)));
|
||
ctx.strokeStyle = serie.color;
|
||
ctx.lineWidth = 2.2;
|
||
ctx.lineJoin = 'round';
|
||
ctx.stroke();
|
||
|
||
// mark only non-zero days: dots on a flat zero line are just noise
|
||
serie.points.forEach((p, i) => {
|
||
if (!p.value) return;
|
||
ctx.beginPath();
|
||
ctx.arc(pts[i].x, pts[i].y, 3.2, 0, Math.PI * 2);
|
||
ctx.fillStyle = serie.color;
|
||
ctx.fill();
|
||
});
|
||
});
|
||
|
||
ctx.fillStyle = '#64748b';
|
||
ctx.textAlign = 'center';
|
||
const every = Math.ceil(count / 8);
|
||
seriesList[0].points.forEach((p, i) => {
|
||
if (i % every === 0 || i === count - 1) ctx.fillText(p.label, padL + i * stepX, h - 8);
|
||
});
|
||
}
|
||
|
||
function drawBarChart(id, series) {
|
||
chartData.set(id, { type: 'bar', series });
|
||
const c = prepareCanvas(id);
|
||
if (!c || !series.length) return;
|
||
const { ctx, w, h } = c;
|
||
const padBottom = 28, padTop = 14;
|
||
const max = Math.max(...series.map((s) => s.value), 1);
|
||
const slot = w / series.length;
|
||
const barW = Math.min(64, slot * 0.5);
|
||
|
||
series.forEach((s, i) => {
|
||
const barH = (s.value / max) * (h - padTop - padBottom);
|
||
const x = i * slot + (slot - barW) / 2;
|
||
const yTop = h - padBottom - barH;
|
||
const grad = ctx.createLinearGradient(0, yTop, 0, h - padBottom);
|
||
grad.addColorStop(0, '#6366f1');
|
||
grad.addColorStop(1, 'rgba(99,102,241,0.25)');
|
||
ctx.fillStyle = grad;
|
||
roundRect(ctx, x, yTop, barW, barH, 6);
|
||
ctx.fill();
|
||
|
||
ctx.fillStyle = '#f8fafc';
|
||
ctx.font = '600 11px Inter, sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(fmtInt(s.value), x + barW / 2, yTop - 5);
|
||
ctx.fillStyle = '#64748b';
|
||
ctx.font = '10px Inter, sans-serif';
|
||
ctx.fillText(s.label, x + barW / 2, h - 9);
|
||
});
|
||
}
|
||
|
||
function drawDonut(id, slices, legendEl) {
|
||
chartData.set(id, { type: 'donut', series: slices, legendEl });
|
||
const c = prepareCanvas(id);
|
||
if (!c) return;
|
||
const { ctx, w, h } = c;
|
||
const total = slices.reduce((s, x) => s + x.value, 0);
|
||
const cx = w / 2, cy = h / 2;
|
||
const r = Math.min(w, h) / 2 - 8;
|
||
const inner = r * 0.62;
|
||
|
||
if (!total) {
|
||
ctx.fillStyle = '#64748b';
|
||
ctx.font = '12px Inter, sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('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) => `
|
||
<span class="legend-item"><i style="background:${s.color}"></i>${s.label}
|
||
<strong>${total ? ((s.value / total) * 100).toFixed(1) : '0.0'}%</strong></span>
|
||
`).join('');
|
||
}
|
||
}
|
||
|
||
function roundRect(ctx, x, y, w, h, r) {
|
||
const radius = Math.min(r, h / 2, w / 2);
|
||
ctx.beginPath();
|
||
ctx.moveTo(x + radius, y);
|
||
ctx.arcTo(x + w, y, x + w, y + h, radius);
|
||
ctx.arcTo(x + w, y + h, x, y + h, radius);
|
||
ctx.arcTo(x, y + h, x, y, radius);
|
||
ctx.arcTo(x, y, x + w, y, radius);
|
||
ctx.closePath();
|
||
}
|
||
|
||
function redrawCharts() {
|
||
chartData.forEach((cfg, id) => {
|
||
if (cfg.type === 'multi') drawMultiLine(id, cfg.series);
|
||
else if (cfg.type === 'line') drawLineChart(id, cfg.series);
|
||
else if (cfg.type === 'bar') drawBarChart(id, cfg.series);
|
||
else drawDonut(id, cfg.series, cfg.legendEl);
|
||
});
|
||
}
|
||
|
||
// ── Navigation & misc events ─────────────────────────────────────────────
|
||
function setupNavigation() {
|
||
const navItems = document.querySelectorAll('.nav-item[data-view]');
|
||
navItems.forEach((item) => {
|
||
item.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
navItems.forEach((n) => n.classList.remove('active'));
|
||
item.classList.add('active');
|
||
document.querySelectorAll('.page-view').forEach((v) =>
|
||
v.classList.toggle('active', v.id === item.dataset.view));
|
||
if (window.innerWidth <= 992) el.sidebar.classList.remove('open');
|
||
redrawCharts();
|
||
|
||
// Opening a section always refetches it: an operator switching to a
|
||
// list expects what the database holds now, not what it held when the
|
||
// console was first opened.
|
||
if (session) refreshView(item.dataset.view, item.dataset.module);
|
||
});
|
||
});
|
||
el.toggleSidebar?.addEventListener('click', () => el.sidebar.classList.toggle('open'));
|
||
}
|
||
|
||
|
||
// Maps a sidebar entry to the loader that owns its data.
|
||
const VIEW_LOADERS = {
|
||
dashboardView: () => { loadStats().catch(() => {}); loadRidesTrend().catch(() => {}); },
|
||
ridesView: () => loadRides().catch(() => {}),
|
||
driversView: () => loadDrivers().catch(() => {}),
|
||
passengersView: () => loadPassengers().catch(() => {}),
|
||
financialsView: () => loadStats().catch(() => {}),
|
||
complaintsView: () => loadStats().catch(() => {}),
|
||
approvalsView: () => loadApprovals().catch(() => {}),
|
||
systemView: () => renderSessionInfo(),
|
||
};
|
||
|
||
function refreshView(viewId, moduleId) {
|
||
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 = `<tr><td colspan="${cols}" class="table-msg ${isError ? 'is-error' : ''}">${esc(message)}</td></tr>`;
|
||
}
|
||
|
||
function setConnection(state, text) {
|
||
if (!el.connectionPill) return;
|
||
el.connectionPill.dataset.state = state;
|
||
el.connectionText.textContent = text;
|
||
}
|
||
|
||
function busy(btn, isBusy, label) {
|
||
if (!btn) return;
|
||
btn.disabled = isBusy;
|
||
const span = btn.querySelector('span');
|
||
if (span) span.textContent = label;
|
||
}
|
||
|
||
const num = (v) => Number(v) || 0;
|
||
const fmtInt = (v) => num(v).toLocaleString('en-US');
|
||
const fmtNum = (v) => num(v).toLocaleString('en-US', { maximumFractionDigits: 2 });
|
||
const fmtMoney = (v) => num(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' JOD';
|
||
|
||
function fmtDate(value, dateOnly = false) {
|
||
if (!value) return '—';
|
||
const d = new Date(String(value).replace(' ', 'T'));
|
||
if (isNaN(d)) return String(value);
|
||
return dateOnly ? d.toLocaleDateString() : d.toLocaleString();
|
||
}
|
||
|
||
function rating(v) {
|
||
const r = Number(v);
|
||
if (!r) return '<span class="stamp">unrated</span>';
|
||
return `<i class="ph-fill ph-star" style="color:var(--warning)"></i> ${r.toFixed(2)}`;
|
||
}
|
||
|
||
function shorten(text, max = 26) {
|
||
if (!text) return '—';
|
||
const s = String(text);
|
||
return s.length > max ? s.slice(0, max - 1) + '…' : s;
|
||
}
|
||
|
||
// Two status generations coexist in the ride table: the legacy CamelCase set
|
||
// and the lowercase set written by the current ride pipeline.
|
||
const STATUS_LABELS = {
|
||
finished: 'Completed', completed: 'Completed',
|
||
begin: 'In progress', started: 'In progress',
|
||
apply: 'Captain assigned', applied: 'Captain assigned',
|
||
accepted: 'Captain assigned', claimed: 'Captain assigned',
|
||
arrived: 'Captain arrived',
|
||
new: 'Waiting', nothing: 'Waiting', waiting: 'Waiting',
|
||
wait: 'Waiting', pending: 'Waiting', searching: 'Searching for a captain',
|
||
cancel: 'Cancelled',
|
||
cancelfromdriver: 'Cancelled by captain',
|
||
cancelfromdriverafterapply: 'Cancelled by captain',
|
||
cancelfrompassenger: 'Cancelled by passenger',
|
||
cancelled_by_driver: 'Cancelled by captain',
|
||
cancelled_by_passenger: 'Cancelled by passenger',
|
||
cancelled_no_driver_found: 'No captain found',
|
||
timeout: 'Timed out', refused: 'Refused',
|
||
pending_review: 'Pending review',
|
||
};
|
||
|
||
function labelStatus(status) {
|
||
if (!status) return '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 = `<i class="ph-fill ${icons[type] || icons.info}"></i><span>${esc(message)}</span>`;
|
||
document.body.appendChild(node);
|
||
setTimeout(() => {
|
||
node.classList.add('leaving');
|
||
setTimeout(() => node.remove(), 300);
|
||
}, 4200);
|
||
}
|
||
})();
|