Add removal/blacklist tools, Arabic UI, and a persistent sign-in error
Authorisation: driver/deleteCaptain.php permanently deletes a captain and had no role check at all — any valid token, including a driver's or passenger's, could delete captains. It now requires super_admin. The two unblacklist endpoints had no role check either and now require an admin role. Console: - Blacklist & removal screen (super admin only): current blocks, permanent removal, and lifting a block. Deletion additionally requires the phone number to be retyped, because the row is deleted outright. - Arabic interface with full RTL layout and a language toggle. Sidebar, content offset, input affordances, toasts and directional icons all mirror; numbers, identifiers and the diagnostics output stay LTR. - Sign-in failures now render in the form and stay there. They were only shown as a toast, which disappears before it can be read — a locked account or a rejected device looked like the page simply returning to the login screen with no explanation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
42f6d33efd
commit
81ee2acefd
+412
-31
@@ -11,7 +11,199 @@
|
||||
// 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-2';
|
||||
const BUILD = '2026-07-25-6';
|
||||
|
||||
// ── 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';
|
||||
@@ -47,6 +239,7 @@
|
||||
|
||||
async function init() {
|
||||
cacheElements();
|
||||
applyLanguage();
|
||||
deviceFingerprint = await resolveFingerprint();
|
||||
if (el.fpPreview) el.fpPreview.textContent = deviceFingerprint.slice(0, 12) + '…';
|
||||
|
||||
@@ -67,7 +260,7 @@
|
||||
|
||||
function cacheElements() {
|
||||
[
|
||||
'authWrapper', 'loginForm', 'loginEmail', 'loginPass', 'loginSubmitBtn', 'fpPreview',
|
||||
'authWrapper', 'loginForm', 'loginEmail', 'loginPass', 'loginSubmitBtn', 'fpPreview', 'loginError',
|
||||
'otpModal', 'otpInput', 'otpPhoneText', 'submitOtpBtn',
|
||||
'sidebar', 'toggleSidebar', 'logoutBtn', 'refreshBtn', 'globalSearch',
|
||||
'connectionPill', 'connectionText', 'lastUpdated',
|
||||
@@ -196,7 +389,7 @@
|
||||
signOut(`Session ended: ${err.message}`);
|
||||
return true;
|
||||
}
|
||||
setConnection('error', 'Data unavailable');
|
||||
setConnection('error', t('Data unavailable'));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -211,13 +404,28 @@
|
||||
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;
|
||||
|
||||
busy(el.loginSubmitBtn, true, 'Verifying…');
|
||||
clearLoginError();
|
||||
busy(el.loginSubmitBtn, true, t('Verifying…'));
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('phone', phone);
|
||||
@@ -238,12 +446,16 @@
|
||||
// Trusted-device renewal path — no OTP required.
|
||||
establishSession(payload);
|
||||
} else {
|
||||
toast('Unexpected response from the authentication service.', 'danger');
|
||||
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, 'Sign In');
|
||||
busy(el.loginSubmitBtn, false, t('Sign In'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,9 +479,10 @@
|
||||
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, 'Verify & Sign In');
|
||||
busy(el.submitOtpBtn, false, t('Verify & Sign In'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,7 +541,7 @@
|
||||
|
||||
// ── Data loading ─────────────────────────────────────────────────────────
|
||||
function loadEverything() {
|
||||
setConnection('loading', 'Loading live data…');
|
||||
setConnection('loading', t('Loading live data…'));
|
||||
Promise.allSettled([
|
||||
loadStats(),
|
||||
loadRides(),
|
||||
@@ -340,10 +553,10 @@
|
||||
renderSessionInfo();
|
||||
const ok = results.some((r) => r.status === 'fulfilled');
|
||||
if (ok) {
|
||||
setConnection('live', 'Live database');
|
||||
el.lastUpdated.textContent = 'Updated ' + new Date().toLocaleTimeString();
|
||||
setConnection('live', t('Live database'));
|
||||
el.lastUpdated.textContent = t('Updated') + ' ' + new Date().toLocaleTimeString();
|
||||
} else if (session) {
|
||||
setConnection('error', 'No data from API');
|
||||
setConnection('error', t('No data from API'));
|
||||
el.lastUpdated.textContent = 'No data — check diagnostics';
|
||||
toast('Every endpoint failed. Open “Session & Security” → Run diagnostics.', 'danger');
|
||||
}
|
||||
@@ -454,15 +667,15 @@
|
||||
setKpi('complaintsMonth', fmtInt(stats.countComplaintThisMonth));
|
||||
|
||||
drawDonut('statusDonut', [
|
||||
{ label: 'Completed', value: num(stats.completed_rides), color: '#10b981' },
|
||||
{ label: 'Waiting', value: num(stats.ongoing_rides), color: '#6366f1' },
|
||||
{ label: 'Cancelled', value: num(stats.cancelled_rides), color: '#f43f5e' },
|
||||
{ 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: 'Morning 6–11', value: num(stats.morning_ride_count) },
|
||||
{ label: 'Afternoon 12–17', value: num(stats.evening_ride_count) },
|
||||
{ label: 'Night 18–5', value: num(stats.night_ride_count) },
|
||||
{ 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();
|
||||
@@ -471,9 +684,9 @@
|
||||
|
||||
function renderServiceMix() {
|
||||
const types = [
|
||||
{ label: 'Comfort', value: num(stats.comfort), color: '#6366f1' },
|
||||
{ label: 'Speed', value: num(stats.speed), color: '#06b6d4' },
|
||||
{ label: 'Lady', value: num(stats.lady), color: '#8b5cf6' },
|
||||
{ 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) => `
|
||||
@@ -489,12 +702,12 @@
|
||||
|
||||
function renderTripPerformance() {
|
||||
const rows = [
|
||||
['Average trip duration', stats.driver_avg_duration || '—'],
|
||||
['Longest trip duration', stats.longest_duration || '—'],
|
||||
['Average distance', `${fmtNum(stats.average_distance)} km`],
|
||||
['Longest distance', `${fmtNum(stats.longest_distance)} km`],
|
||||
['Cancelled rides', fmtInt(stats.cancelled_rides)],
|
||||
['Captains with completed trips', fmtInt(stats.num_Driver)],
|
||||
[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>
|
||||
@@ -970,7 +1183,7 @@
|
||||
groups.forEach((group) => {
|
||||
const label = document.createElement('div');
|
||||
label.className = 'menu-label';
|
||||
label.textContent = group;
|
||||
label.textContent = t(group);
|
||||
menu.appendChild(label);
|
||||
|
||||
MODULES.filter((m) => m.group === group).forEach((mod) => {
|
||||
@@ -982,7 +1195,7 @@
|
||||
item.setAttribute('data-requires-super', '');
|
||||
item.hidden = true;
|
||||
}
|
||||
item.innerHTML = `<i class="ph ${mod.icon}"></i><span>${esc(mod.title)}</span>`;
|
||||
item.innerHTML = `<i class="ph ${mod.icon}"></i><span>${esc(t(mod.title))}</span>`;
|
||||
menu.appendChild(item);
|
||||
|
||||
const section = document.createElement('section');
|
||||
@@ -991,8 +1204,8 @@
|
||||
section.innerHTML = `
|
||||
<div class="page-header">
|
||||
<div class="page-title">
|
||||
<h1>${esc(mod.title)}</h1>
|
||||
<p>${esc(mod.subtitle)}</p>
|
||||
<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}">
|
||||
@@ -1076,6 +1289,173 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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
|
||||
@@ -2444,6 +2824,7 @@
|
||||
|
||||
function setupDataEvents() {
|
||||
el.refreshBtn?.addEventListener('click', () => { if (session) loadEverything(); });
|
||||
$('langToggle')?.addEventListener('click', () => setLanguage(lang === 'ar' ? 'en' : 'ar'));
|
||||
el.rideStatusFilter?.addEventListener('change', () => loadRides().catch(() => {}));
|
||||
|
||||
el.driversPrev?.addEventListener('click', () => {
|
||||
@@ -2565,7 +2946,7 @@
|
||||
|
||||
function labelStatus(status) {
|
||||
if (!status) return 'Unknown';
|
||||
return STATUS_LABELS[String(status).toLowerCase()] || humanize(status);
|
||||
return t(STATUS_LABELS[String(status).toLowerCase()] || humanize(status));
|
||||
}
|
||||
|
||||
function badgeClass(status) {
|
||||
|
||||
Reference in New Issue
Block a user