Fix admin status handling for the current ride pipeline; extend console
The ride table holds two generations of status values: the legacy CamelCase
set ('Finished', 'CancelFromPassenger') and the lowercase set written by
backend/ride/rides/* today ('completed', 'cancelled_by_passenger'). Admin
queries only matched the legacy set, so on live data:
- get_rides_by_status.php returned nothing meaningful for every filter, and
the "in progress" default masked it.
- dashbord.php reported total_driver_earnings as NULL, completed_rides as a
fraction of the real count, and cancelled_rides as 0.
- driver_avg_duration averaged in negative durations, yielding "-00h 22m".
All three now match on LOWER(status) across both families.
Staff/pending.php ran with no authentication at all, exposing pending
admins' names and phone numbers to any caller; it now goes through
connect.php with a role check. It also returned HTTP 400 for everything when
the `users` table was absent — each source is queried independently and
reports its own availability.
Console:
- Render rides from either schema generation (price/date/time and
start_location coordinates, or the older address/created_at columns).
- Null aggregates render as "—" rather than a measured 0.00.
- Add tariff/promo, WhatsApp send and encryption modules, all super-admin
gated; pricing remains read-only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
915a148ebf
commit
852c6ece5c
@@ -3,40 +3,60 @@
|
|||||||
* Admin/Staff/pending.php
|
* Admin/Staff/pending.php
|
||||||
* جلب الحسابات المعلقة للإداريين والخدمة
|
* جلب الحسابات المعلقة للإداريين والخدمة
|
||||||
*/
|
*/
|
||||||
require_once __DIR__ . '/../../core/bootstrap.php';
|
// connect.php يفرض JWT — بدونه كانت هذه النقطة تكشف أسماء وأرقام
|
||||||
require_once __DIR__ . '/../../functions.php';
|
// المشرفين المعلقين لأي زائر بلا أي مصادقة.
|
||||||
|
require_once __DIR__ . '/../../connect.php';
|
||||||
|
|
||||||
|
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['error' => 'Unauthorized: Admin access required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$allPending = [];
|
||||||
|
$sources = [];
|
||||||
|
|
||||||
|
// كل مصدر يُجلب على حدة: غياب جدول users في بعض عمليات النشر كان يُفشل
|
||||||
|
// الطلب بالكامل ويخفي طلبات المشرفين المعلقة أيضاً.
|
||||||
try {
|
try {
|
||||||
$con = Database::get('main');
|
|
||||||
|
|
||||||
// جلب الإداريين المعلقين
|
|
||||||
$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);
|
||||||
|
|
||||||
// فك التشفير للأسماء والأرقام للإداريين
|
|
||||||
foreach ($admins as &$admin) {
|
foreach ($admins as &$admin) {
|
||||||
$admin['name'] = $encryptionHelper->decryptData($admin['name']) ?: $admin['name'];
|
$admin['name'] = $encryptionHelper->decryptData($admin['name']) ?: $admin['name'];
|
||||||
$admin['phone'] = $encryptionHelper->decryptData($admin['phone']) ?: $admin['phone'];
|
$admin['phone'] = $encryptionHelper->decryptData($admin['phone']) ?: $admin['phone'];
|
||||||
}
|
}
|
||||||
|
unset($admin);
|
||||||
|
|
||||||
// جلب موظفي الخدمة المعلقين
|
$allPending = array_merge($allPending, $admins);
|
||||||
|
$sources['admins'] = 'ok';
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log("[Staff Pending] adminUser query failed: " . $e->getMessage());
|
||||||
|
$sources['admins'] = 'unavailable';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
$stmt2 = $con->query("SELECT id, first_name, last_name, phone, user_type as role, created_at, 'service' as type FROM users WHERE status = 'pending' AND user_type = 'service'");
|
$stmt2 = $con->query("SELECT id, first_name, last_name, phone, user_type as role, created_at, 'service' as type FROM users WHERE status = 'pending' AND user_type = 'service'");
|
||||||
$services = $stmt2->fetchAll(PDO::FETCH_ASSOC);
|
$services = $stmt2->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
// فك التشفير لموظفي الخدمة
|
|
||||||
foreach ($services as &$service) {
|
foreach ($services as &$service) {
|
||||||
$service['name'] = trim(($encryptionHelper->decryptData($service['first_name']) ?: $service['first_name']) . ' ' . ($encryptionHelper->decryptData($service['last_name']) ?: $service['last_name']));
|
$service['name'] = trim(
|
||||||
|
($encryptionHelper->decryptData($service['first_name']) ?: $service['first_name']) . ' ' .
|
||||||
|
($encryptionHelper->decryptData($service['last_name']) ?: $service['last_name'])
|
||||||
|
);
|
||||||
$service['phone'] = $encryptionHelper->decryptData($service['phone']) ?: $service['phone'];
|
$service['phone'] = $encryptionHelper->decryptData($service['phone']) ?: $service['phone'];
|
||||||
}
|
}
|
||||||
|
unset($service);
|
||||||
|
|
||||||
$allPending = array_merge($admins, $services);
|
$allPending = array_merge($allPending, $services);
|
||||||
|
$sources['service_staff'] = 'ok';
|
||||||
printSuccess([
|
} catch (Throwable $e) {
|
||||||
"data" => $allPending
|
error_log("[Staff Pending] users query failed: " . $e->getMessage());
|
||||||
]);
|
$sources['service_staff'] = 'unavailable';
|
||||||
|
|
||||||
} catch (Exception $e) {
|
|
||||||
error_log("[Staff Pending Error] " . $e->getMessage());
|
|
||||||
jsonError("An internal error occurred. Please try again later.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
printSuccess([
|
||||||
|
"data" => $allPending,
|
||||||
|
"sources" => $sources,
|
||||||
|
]);
|
||||||
exit();
|
exit();
|
||||||
|
|||||||
@@ -29,14 +29,18 @@ SELECT
|
|||||||
-- المحافظ والتحويلات
|
-- المحافظ والتحويلات
|
||||||
|
|
||||||
-- إحصائيات وقت ومسافة الرحلات
|
-- إحصائيات وقت ومسافة الرحلات
|
||||||
(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) AS driver_avg_duration,
|
-- تُستثنى الفروق السالبة (رحلات سجّلت وقت نهاية أقدم من البداية) لأنها
|
||||||
|
-- كانت تُنتج متوسطاً سالباً مثل "-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 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,
|
||||||
(SELECT ROUND(AVG(distance),2) FROM ride) AS average_distance,
|
(SELECT ROUND(AVG(distance),2) FROM ride) AS average_distance,
|
||||||
(SELECT ROUND(MAX(distance),2) FROM ride) AS longest_distance,
|
(SELECT ROUND(MAX(distance),2) FROM ride) AS longest_distance,
|
||||||
|
|
||||||
-- أرباح السائق والشركة
|
-- أرباح السائق والشركة
|
||||||
(SELECT SUM(price_for_driver) FROM ride WHERE status = 'Finished') AS total_driver_earnings,
|
-- ملاحظة: خط الرحلات الحالي يكتب 'completed' بينما القديم يكتب 'Finished'،
|
||||||
|
-- والاكتفاء بالقديم كان يُرجع NULL للأرباح وصفراً للرحلات المكتملة/الملغاة.
|
||||||
|
(SELECT SUM(price_for_driver) FROM ride WHERE LOWER(status) IN ('finished','completed')) AS total_driver_earnings,
|
||||||
(SELECT ROUND(AVG(price_for_passenger),2) FROM ride) AS avg_passenger_price,
|
(SELECT ROUND(AVG(price_for_passenger),2) FROM ride) AS avg_passenger_price,
|
||||||
|
|
||||||
-- توزيع الرحلات حسب الوقت
|
-- توزيع الرحلات حسب الوقت
|
||||||
@@ -49,10 +53,10 @@ SELECT
|
|||||||
(SELECT COUNT(*) FROM ride WHERE carType = 'Speed') AS speed,
|
(SELECT COUNT(*) FROM ride WHERE carType = 'Speed') AS speed,
|
||||||
(SELECT COUNT(*) FROM ride WHERE carType = 'Lady') AS lady,
|
(SELECT COUNT(*) FROM ride WHERE carType = 'Lady') AS lady,
|
||||||
|
|
||||||
-- حالة الرحلات
|
-- حالة الرحلات (تغطي عائلتي الحالات: القديمة CamelCase والجديدة lowercase)
|
||||||
(SELECT COUNT(*) FROM ride WHERE status = 'wait') AS ongoing_rides,
|
(SELECT COUNT(*) FROM ride WHERE LOWER(status) IN ('wait','waiting','new','nothing','pending','searching')) AS ongoing_rides,
|
||||||
(SELECT COUNT(*) FROM ride WHERE status = 'Finished') AS completed_rides,
|
(SELECT COUNT(*) FROM ride WHERE LOWER(status) IN ('finished','completed')) AS completed_rides,
|
||||||
(SELECT COUNT(*) FROM ride WHERE status = 'cancel') AS cancelled_rides,
|
(SELECT COUNT(*) FROM ride WHERE LOWER(status) LIKE 'cancel%' OR LOWER(status) IN ('timeout','refused')) AS cancelled_rides,
|
||||||
|
|
||||||
-- عدد السائقين الفريدين
|
-- عدد السائقين الفريدين
|
||||||
(SELECT COUNT(*) FROM (SELECT driver_id FROM ride GROUP BY driver_id) AS sub) AS num_Driver,
|
(SELECT COUNT(*) FROM (SELECT driver_id FROM ride GROUP BY driver_id) AS sub) AS num_Driver,
|
||||||
|
|||||||
@@ -18,29 +18,37 @@ try {
|
|||||||
$whereClause = ""; // لا يوجد شرط، اجلب الكل
|
$whereClause = ""; // لا يوجد شرط، اجلب الكل
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
// ملاحظة: قاعدة البيانات تحتوي عائلتين من الحالات — القديمة بصيغة
|
||||||
|
// CamelCase ('Finished','Begin','CancelFromPassenger') والجديدة التي
|
||||||
|
// يكتبها خط الرحلات الحالي بأحرف صغيرة ('completed','accepted',
|
||||||
|
// 'cancelled_by_passenger'). المقارنة تتم بـ LOWER() لتغطية الاثنتين.
|
||||||
case 'Pending':
|
case 'Pending':
|
||||||
// الرحلات المعلقة/الجديدة: بانتظار سائق
|
// الرحلات المعلقة/الجديدة: بانتظار سائق
|
||||||
$whereClause = "WHERE r.status IN ('New','nothing','waiting','wait')";
|
$whereClause = "WHERE LOWER(r.status) IN ('new','nothing','waiting','wait','pending','searching')";
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'Begin':
|
case 'Begin':
|
||||||
// الرحلات الجارية: من قبول السائق إلى بدء التشغيل
|
// الرحلات الجارية: من قبول السائق إلى بدء التشغيل
|
||||||
$whereClause = "WHERE r.status IN ('Apply','Applied','Arrived','arrived','Begin')";
|
$whereClause = "WHERE LOWER(r.status) IN ('apply','applied','arrived','begin','accepted','started','claimed')";
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'Completed':
|
case 'Completed':
|
||||||
// الرحلات المكتملة
|
// الرحلات المكتملة
|
||||||
$whereClause = "WHERE r.status = 'Finished'";
|
$whereClause = "WHERE LOWER(r.status) IN ('finished','completed')";
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'Canceled':
|
case 'Canceled':
|
||||||
// جميع أنواع الإلغاء
|
// جميع أنواع الإلغاء
|
||||||
$whereClause = "WHERE r.status IN ('Cancel','CancelFromDriver','CancelFromDriverAfterApply','CancelFromPassenger','TimeOut')";
|
$whereClause = "WHERE LOWER(r.status) IN (
|
||||||
|
'cancel','cancelfromdriver','cancelfromdriverafterapply','cancelfrompassenger',
|
||||||
|
'timeout','refused','cancelled_by_passenger','cancelled_by_driver',
|
||||||
|
'cancelled_no_driver_found'
|
||||||
|
)";
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// في حال تم إرسال حالة محددة غير المذكورين
|
// في حال تم إرسال حالة محددة غير المذكورين
|
||||||
$whereClause = "WHERE r.status = ?";
|
$whereClause = "WHERE LOWER(r.status) = LOWER(?)";
|
||||||
$params[] = $statusFilter;
|
$params[] = $statusFilter;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1212,3 +1212,6 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
resize: vertical;
|
resize: vertical;
|
||||||
min-height: 84px;
|
min-height: 84px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.coord-link { color: var(--text-muted); text-decoration: none; }
|
||||||
|
.coord-link:hover { color: var(--primary); }
|
||||||
|
|||||||
@@ -499,6 +499,26 @@
|
|||||||
<div class="mini-list" id="sessionInfo"></div>
|
<div class="mini-list" id="sessionInfo"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card" data-requires-super hidden>
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Send WhatsApp message <span class="card-sub">super admin</span></h3>
|
||||||
|
</div>
|
||||||
|
<p class="card-note">
|
||||||
|
Sends a single message through the platform's WhatsApp provider. This leaves the system
|
||||||
|
and reaches a real person — you will be asked to confirm before it is sent.
|
||||||
|
</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="text" class="form-input" id="waReceiver" placeholder="Recipient phone, e.g. 962798583052" style="padding-left:1rem;">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<textarea class="form-input decrypt-area" id="waMessage" rows="3" placeholder="Message text"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="api-base-row">
|
||||||
|
<button class="btn btn-secondary btn-sm" id="waSendBtn"><i class="ph ph-paper-plane-tilt"></i> <span>Send message</span></button>
|
||||||
|
<span class="stamp" id="waStatus"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card" data-requires-super hidden>
|
<div class="card" data-requires-super hidden>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3 class="card-title">Encryption tool <span class="card-sub">super admin</span></h3>
|
<h3 class="card-title">Encryption tool <span class="card-sub">super admin</span></h3>
|
||||||
|
|||||||
+119
-29
@@ -438,8 +438,10 @@
|
|||||||
setKpi('passengers', fmtInt(stats.countPassengers));
|
setKpi('passengers', fmtInt(stats.countPassengers));
|
||||||
setKpi('passengersMonth', fmtInt(stats.countPassengersThisMonth));
|
setKpi('passengersMonth', fmtInt(stats.countPassengersThisMonth));
|
||||||
|
|
||||||
setKpi('driverEarnings', fmtMoney(stats.total_driver_earnings));
|
// A null aggregate means "nothing recorded yet" — showing 0.00 would read
|
||||||
setKpi('avgFare', fmtMoney(stats.avg_passenger_price));
|
// as a measured zero.
|
||||||
|
setKpi('driverEarnings', stats.total_driver_earnings == null ? '—' : fmtMoney(stats.total_driver_earnings));
|
||||||
|
setKpi('avgFare', stats.avg_passenger_price == null ? '—' : fmtMoney(stats.avg_passenger_price));
|
||||||
setKpi('totalDistance', `${fmtInt(stats.total_distance)} km`);
|
setKpi('totalDistance', `${fmtInt(stats.total_distance)} km`);
|
||||||
|
|
||||||
setKpi('complaintsToday', fmtInt(stats.countComplaintToday));
|
setKpi('complaintsToday', fmtInt(stats.countComplaintToday));
|
||||||
@@ -505,20 +507,17 @@
|
|||||||
tableMessage(el.ridesTableBody, 8, 'No rides match this filter.');
|
tableMessage(el.ridesTableBody, 8, 'No rides match this filter.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
el.ridesTableBody.innerHTML = rides.map((r) => {
|
el.ridesTableBody.innerHTML = rides.map((r) => `
|
||||||
const fare = r.price_for_passenger ?? r.price ?? 0;
|
|
||||||
return `
|
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>#${esc(r.id)}</strong></td>
|
<td><strong>#${esc(r.id)}</strong></td>
|
||||||
<td>${esc(r.passenger_full_name || 'Unknown')}</td>
|
<td>${esc(r.passenger_full_name || 'Unknown')}</td>
|
||||||
<td>${esc(r.driver_full_name || 'Unassigned')}</td>
|
<td>${esc(r.driver_full_name || 'Unassigned')}</td>
|
||||||
<td class="route-cell"><i class="ph ph-map-pin"></i> ${esc(shorten(r.address_start))} <i class="ph ph-arrow-right"></i> ${esc(shorten(r.address_end))}</td>
|
<td class="route-cell">${routeCell(r)}</td>
|
||||||
<td><strong>${fmtMoney(fare)}</strong></td>
|
<td><strong>${fmtMoney(rideFare(r))}</strong></td>
|
||||||
<td><span class="badge ${badgeClass(r.status)}">${esc(labelStatus(r.status))}</span></td>
|
<td><span class="badge ${badgeClass(r.status)}">${esc(labelStatus(r.status))}</span></td>
|
||||||
<td>${esc(fmtDate(r.created_at || r.date))}</td>
|
<td>${esc(rideTimestamp(r))}</td>
|
||||||
<td><button class="btn btn-secondary btn-sm" data-ride="${esc(r.id)}"><i class="ph ph-eye"></i></button></td>
|
<td><button class="btn btn-secondary btn-sm" data-ride="${esc(r.id)}"><i class="ph ph-eye"></i></button></td>
|
||||||
</tr>`;
|
</tr>`).join('');
|
||||||
}).join('');
|
|
||||||
|
|
||||||
el.ridesTableBody.querySelectorAll('[data-ride]').forEach((btn) => {
|
el.ridesTableBody.querySelectorAll('[data-ride]').forEach((btn) => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
@@ -528,6 +527,42 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The ride table carries two generations of columns. Older rows expose
|
||||||
|
// address_start/address_end, price_for_passenger and created_at; rows written
|
||||||
|
// by the current ride pipeline expose start_location/end_location as
|
||||||
|
// "lat,lng" pairs, a plain `price`, and separate date + time columns.
|
||||||
|
function rideFare(r) {
|
||||||
|
return r.price_for_passenger ?? r.price ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rideTimestamp(r) {
|
||||||
|
if (r.created_at) return fmtDate(r.created_at);
|
||||||
|
if (r.date) return fmtDate(`${r.date} ${r.time && r.time !== '00:00:00' ? r.time : ''}`.trim());
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeCell(r) {
|
||||||
|
if (r.address_start || r.address_end) {
|
||||||
|
return `<i class="ph ph-map-pin"></i> ${esc(shorten(r.address_start))} <i class="ph ph-arrow-right"></i> ${esc(shorten(r.address_end))}`;
|
||||||
|
}
|
||||||
|
if (r.start_location) {
|
||||||
|
const link = mapLink(r.start_location);
|
||||||
|
return `<a class="coord-link" href="${esc(link)}" target="_blank" rel="noopener">
|
||||||
|
<i class="ph ph-map-pin"></i> ${esc(shortCoord(r.start_location))} <i class="ph ph-arrow-right"></i> ${esc(shortCoord(r.end_location))}
|
||||||
|
</a>`;
|
||||||
|
}
|
||||||
|
return '<span class="stamp">—</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortCoord(value) {
|
||||||
|
if (!value) return '—';
|
||||||
|
return String(value).split(',').map((n) => Number(n).toFixed(4)).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapLink(coords) {
|
||||||
|
return `https://www.openstreetmap.org/?mlat=${encodeURIComponent(String(coords).split(',')[0])}&mlon=${encodeURIComponent(String(coords).split(',')[1] || '')}#map=15/`;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Role model ───────────────────────────────────────────────────────────
|
// ── Role model ───────────────────────────────────────────────────────────
|
||||||
// Mirrors the Flutter admin app: a plain `admin` observes, a `super_admin`
|
// Mirrors the Flutter admin app: a plain `admin` observes, a `super_admin`
|
||||||
// edits, approves and sees unmasked contact details.
|
// edits, approves and sees unmasked contact details.
|
||||||
@@ -812,6 +847,14 @@
|
|||||||
{ title: 'Telemetry', path: '/Admin/marketing/get_telemetry.php' },
|
{ title: 'Telemetry', path: '/Admin/marketing/get_telemetry.php' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
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: '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',
|
||||||
@@ -1121,6 +1164,35 @@
|
|||||||
busy(el.runDiagnosticsBtn, false, 'Run diagnostics');
|
busy(el.runDiagnosticsBtn, false, 'Run diagnostics');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Outbound message — confirmed explicitly because it reaches a real person
|
||||||
|
// and cannot be recalled.
|
||||||
|
async function sendWhatsApp() {
|
||||||
|
if (!isSuperAdmin()) {
|
||||||
|
toast('Sending messages is restricted to super admins.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const receiver = $('waReceiver').value.trim();
|
||||||
|
const message = $('waMessage').value.trim();
|
||||||
|
const status = $('waStatus');
|
||||||
|
|
||||||
|
if (!receiver || !message) {
|
||||||
|
toast('Enter both a recipient and a message.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!confirm(`Send this WhatsApp message to ${receiver}?\n\n${message}`)) return;
|
||||||
|
|
||||||
|
status.textContent = 'Sending…';
|
||||||
|
try {
|
||||||
|
await api('/Admin/send_whatsapp_message.php', { params: { receiver, message } });
|
||||||
|
status.textContent = `Sent to ${receiver}`;
|
||||||
|
$('waMessage').value = '';
|
||||||
|
toast('Message sent.', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
status.textContent = '';
|
||||||
|
toast(`Send failed: ${err.message}`, 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Super-admin only: mirrors the Flutter EncryptToolPage (Admin/ggg.php).
|
// Super-admin only: mirrors the Flutter EncryptToolPage (Admin/ggg.php).
|
||||||
async function runCryptoTool(action) {
|
async function runCryptoTool(action) {
|
||||||
if (!isSuperAdmin()) {
|
if (!isSuperAdmin()) {
|
||||||
@@ -1180,6 +1252,8 @@
|
|||||||
|
|
||||||
document.querySelectorAll('[data-action="decrypt"], [data-action="encrypt"]').forEach((btn) =>
|
document.querySelectorAll('[data-action="decrypt"], [data-action="encrypt"]').forEach((btn) =>
|
||||||
btn.addEventListener('click', () => runCryptoTool(btn.dataset.action)));
|
btn.addEventListener('click', () => runCryptoTool(btn.dataset.action)));
|
||||||
|
|
||||||
|
$('waSendBtn')?.addEventListener('click', sendWhatsApp);
|
||||||
el.copyDiagnosticsBtn.addEventListener('click', async () => {
|
el.copyDiagnosticsBtn.addEventListener('click', async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(el.diagnosticsOutput.textContent);
|
await navigator.clipboard.writeText(el.diagnosticsOutput.textContent);
|
||||||
@@ -1200,14 +1274,16 @@
|
|||||||
['Captain', r.driver_full_name],
|
['Captain', r.driver_full_name],
|
||||||
['Captain phone', maskPhone(r.d_phone)],
|
['Captain phone', maskPhone(r.d_phone)],
|
||||||
['Captain completed / cancelled', `${fmtInt(r.d_completed)} / ${fmtInt(r.d_canceled)}`],
|
['Captain completed / cancelled', `${fmtInt(r.d_completed)} / ${fmtInt(r.d_canceled)}`],
|
||||||
['Pickup', r.address_start],
|
['Pickup', r.address_start || r.start_location],
|
||||||
['Drop-off', r.address_end],
|
['Drop-off', r.address_end || r.end_location],
|
||||||
['Distance', r.distance ? `${fmtNum(r.distance)} km` : '—'],
|
['Distance', r.distance ? `${fmtNum(r.distance)} km` : '—'],
|
||||||
['Passenger fare', fmtMoney(r.price_for_passenger)],
|
['Passenger fare', fmtMoney(rideFare(r))],
|
||||||
['Captain earning', fmtMoney(r.price_for_driver)],
|
['Captain earning', r.price_for_driver ? fmtMoney(r.price_for_driver) : '—'],
|
||||||
|
['Payment method', r.paymentMethod],
|
||||||
['Service', r.carType],
|
['Service', r.carType],
|
||||||
['Started', fmtDate(r.rideTimeStart)],
|
['Requested', rideTimestamp(r)],
|
||||||
['Finished', fmtDate(r.rideTimeFinish)],
|
['Started', r.rideTimeStart ? fmtDate(r.rideTimeStart) : '—'],
|
||||||
|
['Finished', r.rideTimeFinish ? fmtDate(r.rideTimeFinish) : '—'],
|
||||||
['Cancellation note', r.cancel_reason],
|
['Cancellation note', r.cancel_reason],
|
||||||
];
|
];
|
||||||
body.innerHTML = `
|
body.innerHTML = `
|
||||||
@@ -1514,24 +1590,38 @@
|
|||||||
return s.length > max ? s.slice(0, max - 1) + '…' : s;
|
return s.length > max ? s.slice(0, max - 1) + '…' : s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Two status generations coexist in the ride table: the legacy CamelCase set
|
||||||
|
// and the lowercase set written by the current ride pipeline.
|
||||||
|
const STATUS_LABELS = {
|
||||||
|
finished: 'Completed', completed: 'Completed',
|
||||||
|
begin: 'In progress', started: 'In progress',
|
||||||
|
apply: 'Captain assigned', applied: 'Captain assigned',
|
||||||
|
accepted: 'Captain assigned', claimed: 'Captain assigned',
|
||||||
|
arrived: 'Captain arrived',
|
||||||
|
new: 'Waiting', nothing: 'Waiting', waiting: 'Waiting',
|
||||||
|
wait: 'Waiting', pending: 'Waiting', searching: 'Searching for a captain',
|
||||||
|
cancel: 'Cancelled',
|
||||||
|
cancelfromdriver: 'Cancelled by captain',
|
||||||
|
cancelfromdriverafterapply: 'Cancelled by captain',
|
||||||
|
cancelfrompassenger: 'Cancelled by passenger',
|
||||||
|
cancelled_by_driver: 'Cancelled by captain',
|
||||||
|
cancelled_by_passenger: 'Cancelled by passenger',
|
||||||
|
cancelled_no_driver_found: 'No captain found',
|
||||||
|
timeout: 'Timed out', refused: 'Refused',
|
||||||
|
pending_review: 'Pending review',
|
||||||
|
};
|
||||||
|
|
||||||
function labelStatus(status) {
|
function labelStatus(status) {
|
||||||
const map = {
|
if (!status) return 'Unknown';
|
||||||
Finished: 'Completed', Begin: 'In progress', Apply: 'Captain assigned',
|
return STATUS_LABELS[String(status).toLowerCase()] || humanize(status);
|
||||||
Applied: 'Captain assigned', Arrived: 'Captain arrived', arrived: 'Captain arrived',
|
|
||||||
New: 'Waiting', nothing: 'Waiting', waiting: 'Waiting', wait: 'Waiting',
|
|
||||||
Cancel: 'Cancelled', CancelFromDriver: 'Cancelled by captain',
|
|
||||||
CancelFromDriverAfterApply: 'Cancelled by captain', CancelFromPassenger: 'Cancelled by passenger',
|
|
||||||
TimeOut: 'Timed out',
|
|
||||||
};
|
|
||||||
return map[status] || status || 'Unknown';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function badgeClass(status) {
|
function badgeClass(status) {
|
||||||
const s = String(status || '').toLowerCase();
|
const s = String(status || '').toLowerCase();
|
||||||
if (['finished', 'active', 'approved', 'online'].includes(s)) return 'badge-success';
|
if (['finished', 'completed', 'active', 'approved', 'online', 'success'].includes(s)) return 'badge-success';
|
||||||
if (s.startsWith('cancel') || ['timeout', 'suspended', 'rejected', 'blocked'].includes(s)) return 'badge-danger';
|
if (s.startsWith('cancel') || ['timeout', 'refused', 'suspended', 'rejected', 'blocked', 'failure', 'error'].includes(s)) return 'badge-danger';
|
||||||
if (['begin', 'apply', 'applied', 'arrived'].includes(s)) return 'badge-primary';
|
if (['begin', 'apply', 'applied', 'accepted', 'claimed', 'started', 'arrived'].includes(s)) return 'badge-primary';
|
||||||
if (['pending', 'new', 'wait', 'waiting', 'nothing'].includes(s)) return 'badge-warning';
|
if (['pending', 'pending_review', 'new', 'wait', 'waiting', 'nothing', 'searching'].includes(s)) return 'badge-warning';
|
||||||
return 'badge-info';
|
return 'badge-info';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user