Compare commits
4
Commits
852c6ece5c
...
67f55e5192
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67f55e5192 | ||
|
|
a0ab6c5155 | ||
|
|
41a06bba0c | ||
|
|
db4ca7dd7a |
@@ -18,7 +18,28 @@ $sources = [];
|
||||
|
||||
// كل مصدر يُجلب على حدة: غياب جدول users في بعض عمليات النشر كان يُفشل
|
||||
// الطلب بالكامل ويخفي طلبات المشرفين المعلقة أيضاً.
|
||||
/**
|
||||
* بعض عمليات النشر أنشأت adminUser بلا عمود status (انظر schema_primary.sql)،
|
||||
* وعندها لا يمكن تمييز الحسابات المعلقة أصلاً. نفحص العمود أولاً لنُرجع سبباً
|
||||
* واضحاً بدل فشل عام.
|
||||
*/
|
||||
function columnExists(PDO $con, string $table, string $column): bool
|
||||
{
|
||||
try {
|
||||
$stmt = $con->prepare("SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?");
|
||||
$stmt->execute([$table, $column]);
|
||||
return (int) $stmt->fetchColumn() > 0;
|
||||
} catch (Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (!columnExists($con, 'adminUser', 'status')) {
|
||||
throw new RuntimeException("adminUser.status column is missing — admin approvals cannot be tracked until it is added.");
|
||||
}
|
||||
|
||||
$stmt1 = $con->query("SELECT id, name, phone, role, created_at, 'admin' as type FROM adminUser WHERE status = 'pending'");
|
||||
$admins = $stmt1->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
@@ -32,7 +53,7 @@ try {
|
||||
$sources['admins'] = 'ok';
|
||||
} catch (Throwable $e) {
|
||||
error_log("[Staff Pending] adminUser query failed: " . $e->getMessage());
|
||||
$sources['admins'] = 'unavailable';
|
||||
$sources['admins'] = 'unavailable: ' . $e->getMessage();
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -30,7 +30,7 @@ SELECT
|
||||
|
||||
-- إحصائيات وقت ومسافة الرحلات
|
||||
-- تُستثنى الفروق السالبة (رحلات سجّلت وقت نهاية أقدم من البداية) لأنها
|
||||
-- كانت تُنتج متوسطاً سالباً مثل "-00h 22m".
|
||||
-- كانت تُنتج متوسط مدة سالباً.
|
||||
(SELECT TIME_FORMAT(SEC_TO_TIME(AVG(TIMESTAMPDIFF(SECOND, rideTimeStart, rideTimeFinish))), '%Hh %im') FROM ride WHERE rideTimeStart IS NOT NULL AND rideTimeFinish IS NOT NULL AND TIMESTAMPDIFF(SECOND, rideTimeStart, rideTimeFinish) > 0) AS driver_avg_duration,
|
||||
(SELECT MAX(SEC_TO_TIME(TIMESTAMPDIFF(SECOND, rideTimeStart, rideTimeFinish))) FROM ride WHERE rideTimeStart IS NOT NULL AND rideTimeFinish IS NOT NULL) AS longest_duration,
|
||||
(SELECT ROUND(SUM(distance),2) FROM ride) AS total_distance,
|
||||
|
||||
+31
-4
@@ -4,17 +4,33 @@
|
||||
// أداة تشفير وفك تشفير للمشرفين
|
||||
// ============================================================
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
// ============================================================
|
||||
// المصادقة: هذه الأداة تفك تشفير أي حقل في قاعدة البيانات، لذا تمر عبر
|
||||
// connect.php (JWT + بصمة الجهاز + Rate limiting) ثم تتطلب دور super_admin.
|
||||
//
|
||||
// سابقاً كان الإذن الوحيد هو رقم هاتف يُرسل داخل جسم الطلب نفسه — وهو ليس
|
||||
// سرّاً: أي شخص يعرف رقماً من القائمة كان يستطيع فك تشفير بيانات المنصة
|
||||
// كاملةً بلا تسجيل دخول. أُبقيت قائمة الأرقام كطبقة ثانية فوق التوكن.
|
||||
// ============================================================
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
|
||||
// نضمن أن الرد دائماً JSON
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
if ($role !== 'super_admin') {
|
||||
securityLog("Unauthorized encrypt/decrypt attempt", [
|
||||
'user_id' => $user_id ?? 'unknown',
|
||||
'role' => $role ?? 'none',
|
||||
]);
|
||||
jsonError('Forbidden. Super Admin access required.', 403);
|
||||
}
|
||||
|
||||
// 1) قراءة الـ body كـ JSON أو POST
|
||||
$action = filterRequest('action');
|
||||
$text = filterRequest('text');
|
||||
$adminPhoneParam = filterRequest('admin_phone');
|
||||
|
||||
// 2) التحقق من رقم هاتف الأدمن المصرّح له
|
||||
// 2) طبقة ثانية: رقم الهاتف يجب أن يكون ضمن القائمة المصرّح لها (إن وُجدت)
|
||||
$phonesRaw = getenv('ADMIN_PHONE_NUMBERS') ?: '';
|
||||
$ALLOWED_TOOL_PHONES = array_values(
|
||||
array_filter(
|
||||
@@ -26,11 +42,22 @@ $ALLOWED_TOOL_PHONES = array_values(
|
||||
|
||||
$adminPhoneParam = $adminPhoneParam ? preg_replace('/\D+/', '', $adminPhoneParam) : '';
|
||||
|
||||
if ($adminPhoneParam === '' || !in_array($adminPhoneParam, $ALLOWED_TOOL_PHONES, true)) {
|
||||
securityLog("Unauthorized encrypt/decrypt attempt", ['phone' => $adminPhoneParam]);
|
||||
if (!empty($ALLOWED_TOOL_PHONES)
|
||||
&& ($adminPhoneParam === '' || !in_array($adminPhoneParam, $ALLOWED_TOOL_PHONES, true))) {
|
||||
securityLog("Encrypt/decrypt phone not in allow-list", [
|
||||
'user_id' => $user_id ?? 'unknown',
|
||||
'phone' => $adminPhoneParam,
|
||||
]);
|
||||
jsonError('Access denied for this admin phone.', 403);
|
||||
}
|
||||
|
||||
// 3) سجل تدقيق: كل استخدام لهذه الأداة يُسجَّل مع هوية المنفّذ
|
||||
securityLog("Encryption tool used", [
|
||||
'user_id' => $user_id ?? 'unknown',
|
||||
'action' => $action,
|
||||
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
|
||||
]);
|
||||
|
||||
if (empty($text) || ($action !== 'encrypt' && $action !== 'decrypt')) {
|
||||
jsonError('Invalid input: need action=encrypt|decrypt and non-empty text.', 400);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
/**
|
||||
* Admin/notifications/broadcast.php
|
||||
* إرسال إشعار جماعي إلى كل السائقين أو كل الركاب.
|
||||
*
|
||||
* لماذا نقطة وسيطة بدل استدعاء ride/firebase/send_fcm.php من الواجهة؟
|
||||
* - send_fcm.php داخلية ومحمية بمفتاح سرّي (FCM_INTERNAL_API_KEY)، ولا يجوز
|
||||
* أن يحمل المتصفح هذا المفتاح لأنه سيُكشف لأي مستخدم.
|
||||
* - send_fcm.php لا تعرف من المُرسِل، فلا تستطيع تقييد الصلاحية ولا التدقيق.
|
||||
*
|
||||
* هذه النقطة تفرض JWT + بصمة الجهاز (عبر connect.php) ودور super_admin، ثم
|
||||
* تُمرّر الطلب داخلياً مع المفتاح السرّي وتسجّل العملية في سجل التدقيق.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
// إشعار جماعي يصل كل مستخدمي المنصة فوراً ولا يمكن سحبه بعد الإرسال.
|
||||
if ($role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'status' => 'failure',
|
||||
'message' => 'Forbidden. Super Admin access required to broadcast notifications.',
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$audience = filterRequest('audience');
|
||||
$title = filterRequest('title');
|
||||
$body = filterRequest('body');
|
||||
|
||||
// المواضيع المسموح بها فقط — يشترك بها التطبيقان (siro_driver / siro_rider).
|
||||
// قصرها على قائمة ثابتة يمنع استخدام النقطة لبثّ رسائل إلى مواضيع عشوائية
|
||||
// أو إلى توكن جهاز بعينه.
|
||||
$ALLOWED_AUDIENCES = [
|
||||
'drivers' => 'drivers',
|
||||
'passengers' => 'passengers',
|
||||
];
|
||||
|
||||
if (!isset($ALLOWED_AUDIENCES[$audience])) {
|
||||
jsonError('Invalid audience. Allowed: ' . implode(', ', array_keys($ALLOWED_AUDIENCES)), 400);
|
||||
}
|
||||
|
||||
$title = trim((string) $title);
|
||||
$body = trim((string) $body);
|
||||
|
||||
if ($title === '' || $body === '') {
|
||||
jsonError('Both title and body are required.', 400);
|
||||
}
|
||||
if (mb_strlen($title) > 120) {
|
||||
jsonError('Title is too long (max 120 characters).', 400);
|
||||
}
|
||||
if (mb_strlen($body) > 1000) {
|
||||
jsonError('Body is too long (max 1000 characters).', 400);
|
||||
}
|
||||
|
||||
$topic = $ALLOWED_AUDIENCES[$audience];
|
||||
|
||||
// سجل التدقيق قبل الإرسال: نريد أثراً حتى لو فشل النداء أو انقطع.
|
||||
securityLog("Broadcast notification requested", [
|
||||
'user_id' => $user_id ?? 'unknown',
|
||||
'audience' => $audience,
|
||||
'title' => $title,
|
||||
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
|
||||
]);
|
||||
|
||||
if (function_exists('logAudit')) {
|
||||
try {
|
||||
logAudit($con, (string) ($user_id ?? 'unknown'), 'إرسال إشعار جماعي', 'notification', $topic, [
|
||||
'audience' => $audience,
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
error_log("[Broadcast] audit log failed: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// الاستدعاء الداخلي لخدمة FCM
|
||||
$fcmUrl = getenv('FCM_INTERNAL_URL') ?: 'http://127.0.0.1/backend/ride/firebase/send_fcm.php';
|
||||
$payload = json_encode([
|
||||
'target' => $topic,
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'isTopic' => true,
|
||||
'data' => ['category' => 'admin_broadcast'],
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$headers = ['Content-Type: application/json; charset=UTF-8'];
|
||||
$internalKey = getenv('FCM_INTERNAL_API_KEY');
|
||||
if (!empty($internalKey)) {
|
||||
$headers[] = 'X-API-KEY: ' . $internalKey;
|
||||
}
|
||||
|
||||
$ch = curl_init($fcmUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 20,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlErr = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false || $httpCode >= 400) {
|
||||
error_log("[Broadcast] FCM call failed (HTTP $httpCode): " . ($curlErr ?: $response));
|
||||
jsonError("Notification service rejected the request (HTTP $httpCode).", 502);
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) $response, true);
|
||||
|
||||
jsonSuccess([
|
||||
'audience' => $audience,
|
||||
'topic' => $topic,
|
||||
'title' => $title,
|
||||
'sent_by' => $user_id ?? null,
|
||||
'sent_at' => date('Y-m-d H:i:s'),
|
||||
'fcm_status' => $decoded['status'] ?? 'unknown',
|
||||
], 'Broadcast delivered to the notification service.');
|
||||
@@ -1,6 +1,18 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
// حارس الصلاحيات: هذه النقطة تعدّل التسعير/الأكواد الترويجية على الإنتاج.
|
||||
// connect.php يتحقق من صحة التوكن فقط، لذا بدون هذا الفحص كان أي توكن صالح
|
||||
// (سائق أو راكب) قادراً على تعديلها.
|
||||
if ($role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'status' => 'failure',
|
||||
'message' => 'Forbidden. Super Admin access required.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$kazanPercent = filterRequest("kazanPercent") ?: filterRequest("kazan");
|
||||
$adminId = filterRequest("adminId");
|
||||
$fuelPrice = filterRequest("fuelPrice");
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
// هذه النقطة تغيّر أجور الركاب على الإنتاج فوراً. connect.php يتحقق من صحة
|
||||
// التوكن فقط — وبدون فحص الدور كان أي توكن صالح (سائق أو راكب) قادراً على
|
||||
// تعديل تسعير المنصة بالكامل.
|
||||
if ($role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'status' => 'failure',
|
||||
'message' => 'Forbidden. Super Admin access required to change pricing.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = filterRequest("id");
|
||||
|
||||
$allowedFields = [
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
// حارس الصلاحيات: هذه النقطة تعدّل التسعير/الأكواد الترويجية على الإنتاج.
|
||||
// connect.php يتحقق من صحة التوكن فقط، لذا بدون هذا الفحص كان أي توكن صالح
|
||||
// (سائق أو راكب) قادراً على تعديلها.
|
||||
if ($role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'status' => 'failure',
|
||||
'message' => 'Forbidden. Super Admin access required.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$promo_code = filterRequest("promo_code");
|
||||
$amount = filterRequest("amount");
|
||||
$description = filterRequest("description");
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
// حارس الصلاحيات: هذه النقطة تعدّل التسعير/الأكواد الترويجية على الإنتاج.
|
||||
// connect.php يتحقق من صحة التوكن فقط، لذا بدون هذا الفحص كان أي توكن صالح
|
||||
// (سائق أو راكب) قادراً على تعديلها.
|
||||
if ($role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'status' => 'failure',
|
||||
'message' => 'Forbidden. Super Admin access required.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = filterRequest("id");
|
||||
|
||||
$sql = "DELETE FROM `promos` WHERE `id` = :id";
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
// حارس الصلاحيات: هذه النقطة تعدّل التسعير/الأكواد الترويجية على الإنتاج.
|
||||
// connect.php يتحقق من صحة التوكن فقط، لذا بدون هذا الفحص كان أي توكن صالح
|
||||
// (سائق أو راكب) قادراً على تعديلها.
|
||||
if ($role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'status' => 'failure',
|
||||
'message' => 'Forbidden. Super Admin access required.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = filterRequest("id");
|
||||
if (empty($id)) {
|
||||
jsonError("ID is required for update");
|
||||
|
||||
@@ -1215,3 +1215,172 @@ h1, h2, h3, h4, h5, h6 {
|
||||
|
||||
.coord-link { color: var(--text-muted); text-decoration: none; }
|
||||
.coord-link:hover { color: var(--primary); }
|
||||
|
||||
/* Tariff editor */
|
||||
.notice-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.7rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.notice-card i { font-size: 1.2rem; color: var(--info); flex-shrink: 0; }
|
||||
.notice-danger { border-color: rgba(244, 63, 94, 0.35); }
|
||||
.notice-danger i { color: var(--danger); }
|
||||
.notice-card strong { color: var(--text-main); }
|
||||
|
||||
.tariff-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.tariff-field { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
|
||||
.tariff-label {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tariff-label em {
|
||||
font-style: normal;
|
||||
color: var(--text-subtle);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.tariff-field .form-input { padding-left: 1rem; font-size: 0.9rem; }
|
||||
.tariff-field .form-input:disabled { opacity: 0.65; cursor: not-allowed; }
|
||||
|
||||
/* Route approvals */
|
||||
.stop-list {
|
||||
margin: 0;
|
||||
padding-left: 1.2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.stop-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stop-list li span:first-child { color: var(--text-main); }
|
||||
|
||||
/* Broadcast preview */
|
||||
.push-preview {
|
||||
max-width: 420px;
|
||||
padding: 1rem 1.15rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.push-app {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-subtle);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.push-app i { color: var(--primary); }
|
||||
|
||||
.push-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
margin-bottom: 0.2rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.push-body {
|
||||
font-size: 0.86rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Bidirectional text: names, addresses and messages are often Arabic while the
|
||||
UI chrome is English. `plaintext` lets each value pick its own direction
|
||||
from its first strong character instead of inheriting the page's LTR. */
|
||||
.form-input,
|
||||
.data-table td,
|
||||
.push-title,
|
||||
.push-body,
|
||||
.kv-row strong,
|
||||
.stop-list li span:first-child,
|
||||
.kpi-tile-value {
|
||||
unicode-bidi: plaintext;
|
||||
}
|
||||
|
||||
textarea.form-input { text-align: start; }
|
||||
|
||||
/* Driver document review */
|
||||
.doc-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.doc-card {
|
||||
margin: 0;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.doc-card img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
object-fit: cover;
|
||||
background: rgba(2, 6, 23, 0.6);
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.doc-card img:hover { opacity: 0.85; }
|
||||
|
||||
.doc-missing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
height: 150px;
|
||||
color: var(--text-subtle);
|
||||
font-size: 0.8rem;
|
||||
background: rgba(2, 6, 23, 0.6);
|
||||
}
|
||||
|
||||
.doc-card figcaption {
|
||||
padding: 0.6rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-main);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.doc-card figcaption .stamp { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* .btn-primary is full-width by default (login form); inline uses must not be */
|
||||
.btn-primary.btn-sm {
|
||||
width: auto;
|
||||
padding: 0.4rem 0.9rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.card-header { gap: 1rem; flex-wrap: wrap; }
|
||||
|
||||
+744
-14
@@ -848,18 +848,33 @@
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'tariff', group: 'Growth & Pricing', icon: 'ph-currency-circle-dollar', title: 'Tariff & Promos',
|
||||
subtitle: 'The live Kazan tariff table and active promo codes (read-only)',
|
||||
panels: [
|
||||
{ title: 'Kazan tariff', path: '/ride/kazan/get.php' },
|
||||
{ title: 'Promo codes', path: '/ride/promo/get.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', group: 'Quality', icon: 'ph-prohibit', title: 'Blacklist',
|
||||
subtitle: 'Blocked captains and passengers',
|
||||
@@ -870,18 +885,49 @@
|
||||
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 and their pending routes',
|
||||
panels: [
|
||||
{ title: 'Organisations', path: '/Admin/transit/org/list.php' },
|
||||
{ title: 'Routes awaiting approval', path: '/Admin/transit/route/pending.php' },
|
||||
],
|
||||
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: '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',
|
||||
panels: [{ title: 'Employees', path: '/Admin/employee/get.php' }],
|
||||
subtitle: 'Internal staff records, activation and onboarding',
|
||||
custom: renderStaff,
|
||||
},
|
||||
{
|
||||
id: 'audit', group: 'Administration', icon: 'ph-scroll', title: 'Audit Log',
|
||||
@@ -949,6 +995,11 @@
|
||||
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>
|
||||
@@ -967,6 +1018,685 @@
|
||||
}));
|
||||
}
|
||||
|
||||
// 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(); });
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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, '\\$&');
|
||||
}
|
||||
@@ -1010,7 +1740,7 @@
|
||||
grid.className = 'kpi-tiles';
|
||||
grid.innerHTML = scalars.map(([k, v]) => `
|
||||
<div class="kpi-tile">
|
||||
<div class="kpi-tile-value">${esc(formatValue(v, k))}</div>
|
||||
<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);
|
||||
|
||||
Reference in New Issue
Block a user