Compare commits

4 Commits
Author SHA1 Message Date
Hamza-AyedandClaude Opus 5 67f55e5192 Add driver document review and staff onboarding
Driver documents: paged list from auth/driver/drivers_pending_list.php, a
detail panel showing each uploaded document as a thumbnail linking to the
full image, and activation via Admin/driver/updateDriverFromAdmin.php. The
confirmation states how many documents were reviewed and warns explicitly
when a captain has none on file, since approving then activates an
unverified account.

Details are requested as a POST body. The mobile app calls this endpoint as
GET "?id=", which filterRequest() never reads, so its detail lookup cannot
be receiving an id at all.

Staff: pending admin/service accounts with per-account activation via
Staff/activate.php, the employee list, and a creation form posting to
Staff/add.php. Administrator accounts are offered only to super admins,
matching add.php's own check; passwords are rejected below 8 characters and
cleared from the form after submission.

Both screens mask phone numbers for plain admins and never render
token/password/fingerprint fields.

Also stop .btn-primary stretching to full width when used inline in a card
header — it is styled for the login form.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 14:30:49 +03:00
Hamza-AyedandClaude Opus 5 a0ab6c5155 Add broadcast notifications and transit route approvals
Broadcast: ride/firebase/send_fcm.php is an internal service guarded by a
shared secret, so the browser cannot call it — holding that key client-side
would expose it, and the endpoint cannot tell who the sender is. A new
Admin/notifications/broadcast.php sits in front of it: it runs behind
connect.php, requires super_admin, restricts the target to the two topics the
apps actually subscribe to ('drivers'/'passengers') so it cannot be used to
push to an arbitrary topic or a single device token, bounds the title and
body, writes an audit entry before dispatching, and only then forwards the
call internally with the shared secret.

The composer shows a live push preview and an explicit confirmation naming
the audience, since a broadcast cannot be recalled.

Route approvals: draft routes render with their stops, distance and stop
count, and approve/reject posts to transit/route/approve.php behind a
confirmation stating the consequence. Available to admins and super admins,
matching the endpoint's own role check.

Also render user-supplied text with unicode-bidi: plaintext — Arabic names,
addresses and messages were being laid out left-to-right inside the
English UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 02:15:02 +03:00
Hamza-AyedandClaude Opus 5 41a06bba0c Add fleet, invoice, promo and lookup-driven admin modules
Brings the console to 18 modules across 26 navigation entries.

- Fleet performance (best captains, per-captain card charges), invoice
  totals and promo codes as read-only panels.
- Ride monitor: monitorRide.php and getDriverGiftPayment.php answer only for
  a given phone number, so they get an input rather than a panel that would
  render an error on load.
- Status-like values in generated tiles use the same label mapping as the
  tables, so raw values such as cancelled_by_passenger no longer leak into
  the UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 02:02:08 +03:00
Hamza-AyedandClaude Opus 5 db4ca7dd7a Fix dashbord.php parse error; require super_admin on pricing and crypto tools
Hotfix: a comment added to the dashboard SQL contained double quotes inside
the double-quoted PHP string, terminating it and making dashbord.php fail to
parse. Production was returning a parse error for every dashboard request.

Authorisation gaps closed — connect.php only proves a token is valid, it does
not check what the caller is allowed to do:

- Admin/ggg.php decrypts any database field and was authorised solely by an
  admin phone number sent in the request body. Anyone who knew a listed
  number could decrypt platform data without signing in. It now runs behind
  connect.php, requires super_admin, keeps the phone list as a second factor,
  and records every use.
- ride/kazan/update.php, kazan/add.php and ride/promo/{add,update,delete}.php
  changed live pricing and discount codes with no role check at all, so any
  valid token — including a driver's or passenger's — could rewrite the fare
  table. All now require super_admin.

Staff/pending.php: adminUser has no `status` column in this deployment, so
the query failed with an opaque "unavailable". It now checks for the column
and reports the actual reason.

Console: Kazan tariff editor for super admins — sends only changed fields,
shows an old → new confirmation before saving, and stays read-only with an
explanatory notice for plain admins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 02:00:14 +03:00
11 changed files with 1149 additions and 20 deletions
+22 -1
View File
@@ -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 {
+1 -1
View File
@@ -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
View File
@@ -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);
}
+122
View File
@@ -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.');
+12
View File
@@ -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");
+12
View File
@@ -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 = [
+12
View File
@@ -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");
+12
View File
@@ -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";
+12
View File
@@ -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");
+169
View File
@@ -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
View File
@@ -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 &amp; 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 &amp; 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 &amp; 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);