Compare commits
4
Commits
852c6ece5c
...
67f55e5192
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67f55e5192 | ||
|
|
a0ab6c5155 | ||
|
|
41a06bba0c | ||
|
|
db4ca7dd7a |
@@ -18,7 +18,28 @@ $sources = [];
|
|||||||
|
|
||||||
// كل مصدر يُجلب على حدة: غياب جدول users في بعض عمليات النشر كان يُفشل
|
// كل مصدر يُجلب على حدة: غياب جدول users في بعض عمليات النشر كان يُفشل
|
||||||
// الطلب بالكامل ويخفي طلبات المشرفين المعلقة أيضاً.
|
// الطلب بالكامل ويخفي طلبات المشرفين المعلقة أيضاً.
|
||||||
|
/**
|
||||||
|
* بعض عمليات النشر أنشأت adminUser بلا عمود status (انظر schema_primary.sql)،
|
||||||
|
* وعندها لا يمكن تمييز الحسابات المعلقة أصلاً. نفحص العمود أولاً لنُرجع سبباً
|
||||||
|
* واضحاً بدل فشل عام.
|
||||||
|
*/
|
||||||
|
function columnExists(PDO $con, string $table, string $column): bool
|
||||||
|
{
|
||||||
try {
|
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'");
|
$stmt1 = $con->query("SELECT id, name, phone, role, created_at, 'admin' as type FROM adminUser WHERE status = 'pending'");
|
||||||
$admins = $stmt1->fetchAll(PDO::FETCH_ASSOC);
|
$admins = $stmt1->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
@@ -32,7 +53,7 @@ try {
|
|||||||
$sources['admins'] = 'ok';
|
$sources['admins'] = 'ok';
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
error_log("[Staff Pending] adminUser query failed: " . $e->getMessage());
|
error_log("[Staff Pending] adminUser query failed: " . $e->getMessage());
|
||||||
$sources['admins'] = 'unavailable';
|
$sources['admins'] = 'unavailable: ' . $e->getMessage();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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 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 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,
|
(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
|
// نضمن أن الرد دائماً JSON
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
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
|
// 1) قراءة الـ body كـ JSON أو POST
|
||||||
$action = filterRequest('action');
|
$action = filterRequest('action');
|
||||||
$text = filterRequest('text');
|
$text = filterRequest('text');
|
||||||
$adminPhoneParam = filterRequest('admin_phone');
|
$adminPhoneParam = filterRequest('admin_phone');
|
||||||
|
|
||||||
// 2) التحقق من رقم هاتف الأدمن المصرّح له
|
// 2) طبقة ثانية: رقم الهاتف يجب أن يكون ضمن القائمة المصرّح لها (إن وُجدت)
|
||||||
$phonesRaw = getenv('ADMIN_PHONE_NUMBERS') ?: '';
|
$phonesRaw = getenv('ADMIN_PHONE_NUMBERS') ?: '';
|
||||||
$ALLOWED_TOOL_PHONES = array_values(
|
$ALLOWED_TOOL_PHONES = array_values(
|
||||||
array_filter(
|
array_filter(
|
||||||
@@ -26,11 +42,22 @@ $ALLOWED_TOOL_PHONES = array_values(
|
|||||||
|
|
||||||
$adminPhoneParam = $adminPhoneParam ? preg_replace('/\D+/', '', $adminPhoneParam) : '';
|
$adminPhoneParam = $adminPhoneParam ? preg_replace('/\D+/', '', $adminPhoneParam) : '';
|
||||||
|
|
||||||
if ($adminPhoneParam === '' || !in_array($adminPhoneParam, $ALLOWED_TOOL_PHONES, true)) {
|
if (!empty($ALLOWED_TOOL_PHONES)
|
||||||
securityLog("Unauthorized encrypt/decrypt attempt", ['phone' => $adminPhoneParam]);
|
&& ($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);
|
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')) {
|
if (empty($text) || ($action !== 'encrypt' && $action !== 'decrypt')) {
|
||||||
jsonError('Invalid input: need action=encrypt|decrypt and non-empty text.', 400);
|
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
|
<?php
|
||||||
require_once __DIR__ . '/../../connect.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");
|
$kazanPercent = filterRequest("kazanPercent") ?: filterRequest("kazan");
|
||||||
$adminId = filterRequest("adminId");
|
$adminId = filterRequest("adminId");
|
||||||
$fuelPrice = filterRequest("fuelPrice");
|
$fuelPrice = filterRequest("fuelPrice");
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once __DIR__ . '/../../connect.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");
|
$id = filterRequest("id");
|
||||||
|
|
||||||
$allowedFields = [
|
$allowedFields = [
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once __DIR__ . '/../../connect.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");
|
$promo_code = filterRequest("promo_code");
|
||||||
$amount = filterRequest("amount");
|
$amount = filterRequest("amount");
|
||||||
$description = filterRequest("description");
|
$description = filterRequest("description");
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once __DIR__ . '/../../connect.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");
|
$id = filterRequest("id");
|
||||||
|
|
||||||
$sql = "DELETE FROM `promos` WHERE `id` = :id";
|
$sql = "DELETE FROM `promos` WHERE `id` = :id";
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once __DIR__ . '/../../connect.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");
|
$id = filterRequest("id");
|
||||||
if (empty($id)) {
|
if (empty($id)) {
|
||||||
jsonError("ID is required for update");
|
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 { color: var(--text-muted); text-decoration: none; }
|
||||||
.coord-link:hover { color: var(--primary); }
|
.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',
|
id: 'tariff', group: 'Growth & Pricing', icon: 'ph-currency-circle-dollar', title: 'Tariff Editor',
|
||||||
subtitle: 'The live Kazan tariff table and active promo codes (read-only)',
|
subtitle: 'The live Kazan tariff — every change here alters what passengers pay',
|
||||||
panels: [
|
custom: renderTariffEditor,
|
||||||
{ title: 'Kazan tariff', path: '/ride/kazan/get.php' },
|
},
|
||||||
{ title: 'Promo codes', path: '/ride/promo/get.php' },
|
{
|
||||||
],
|
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',
|
id: 'geofence', group: 'Growth & Pricing', icon: 'ph-map-trifold', title: 'Demand Heatmap',
|
||||||
subtitle: 'Geofenced demand density',
|
subtitle: 'Geofenced demand density',
|
||||||
panels: [{ title: 'Heatmap', path: '/Admin/geofence/get_heatmap.php' }],
|
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',
|
id: 'quality', group: 'Quality', icon: 'ph-prohibit', title: 'Blacklist',
|
||||||
subtitle: 'Blocked captains and passengers',
|
subtitle: 'Blocked captains and passengers',
|
||||||
@@ -870,18 +885,49 @@
|
|||||||
subtitle: 'Behaviour and reliability scoring per captain',
|
subtitle: 'Behaviour and reliability scoring per captain',
|
||||||
panels: [{ title: 'Scorecard', path: '/Admin/v2/quality/driver_scorecard.php' }],
|
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',
|
id: 'transit', group: 'Transit', icon: 'ph-bus', title: 'Mawasalati Organisations',
|
||||||
subtitle: 'Registered transit organisations and their pending routes',
|
subtitle: 'Registered transit organisations',
|
||||||
panels: [
|
panels: [{ title: 'Organisations', path: '/Admin/transit/org/list.php' }],
|
||||||
{ title: 'Organisations', path: '/Admin/transit/org/list.php' },
|
},
|
||||||
{ title: 'Routes awaiting approval', path: '/Admin/transit/route/pending.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',
|
id: 'staff', superOnly: true, group: 'Administration', icon: 'ph-identification-badge', title: 'Staff & Employees',
|
||||||
subtitle: 'Internal staff records',
|
subtitle: 'Internal staff records, activation and onboarding',
|
||||||
panels: [{ title: 'Employees', path: '/Admin/employee/get.php' }],
|
custom: renderStaff,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'audit', group: 'Administration', icon: 'ph-scroll', title: 'Audit Log',
|
id: 'audit', group: 'Administration', icon: 'ph-scroll', title: 'Audit Log',
|
||||||
@@ -949,6 +995,11 @@
|
|||||||
loadedModules.add(mod.id);
|
loadedModules.add(mod.id);
|
||||||
|
|
||||||
const host = $(`panels_${mod.id}`);
|
const host = $(`panels_${mod.id}`);
|
||||||
|
if (mod.custom) {
|
||||||
|
await mod.custom(host);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
host.innerHTML = mod.panels.map((p) => `
|
host.innerHTML = mod.panels.map((p) => `
|
||||||
<div class="card" data-panel="${esc(p.path)}">
|
<div class="card" data-panel="${esc(p.path)}">
|
||||||
<div class="card-header"><h3 class="card-title">${esc(p.title)}</h3></div>
|
<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) {
|
function cssEscape(value) {
|
||||||
return String(value).replace(/["\\]/g, '\\$&');
|
return String(value).replace(/["\\]/g, '\\$&');
|
||||||
}
|
}
|
||||||
@@ -1010,7 +1740,7 @@
|
|||||||
grid.className = 'kpi-tiles';
|
grid.className = 'kpi-tiles';
|
||||||
grid.innerHTML = scalars.map(([k, v]) => `
|
grid.innerHTML = scalars.map(([k, v]) => `
|
||||||
<div class="kpi-tile">
|
<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 class="kpi-tile-label">${esc(humanize(k))}</div>
|
||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
frag.appendChild(grid);
|
frag.appendChild(grid);
|
||||||
|
|||||||
Reference in New Issue
Block a user