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:
Hamza-Ayed
2026-07-25 01:47:55 +03:00
co-authored by Claude Opus 5
parent 915a148ebf
commit 852c6ece5c
6 changed files with 204 additions and 59 deletions
+3
View File
@@ -1212,3 +1212,6 @@ h1, h2, h3, h4, h5, h6 {
resize: vertical;
min-height: 84px;
}
.coord-link { color: var(--text-muted); text-decoration: none; }
.coord-link:hover { color: var(--primary); }
+20
View File
@@ -499,6 +499,26 @@
<div class="mini-list" id="sessionInfo"></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-header">
<h3 class="card-title">Encryption tool <span class="card-sub">super admin</span></h3>
+119 -29
View File
@@ -438,8 +438,10 @@
setKpi('passengers', fmtInt(stats.countPassengers));
setKpi('passengersMonth', fmtInt(stats.countPassengersThisMonth));
setKpi('driverEarnings', fmtMoney(stats.total_driver_earnings));
setKpi('avgFare', fmtMoney(stats.avg_passenger_price));
// A null aggregate means "nothing recorded yet" — showing 0.00 would read
// 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('complaintsToday', fmtInt(stats.countComplaintToday));
@@ -505,20 +507,17 @@
tableMessage(el.ridesTableBody, 8, 'No rides match this filter.');
return;
}
el.ridesTableBody.innerHTML = rides.map((r) => {
const fare = r.price_for_passenger ?? r.price ?? 0;
return `
el.ridesTableBody.innerHTML = rides.map((r) => `
<tr>
<td><strong>#${esc(r.id)}</strong></td>
<td>${esc(r.passenger_full_name || 'Unknown')}</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><strong>${fmtMoney(fare)}</strong></td>
<td class="route-cell">${routeCell(r)}</td>
<td><strong>${fmtMoney(rideFare(r))}</strong></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>
</tr>`;
}).join('');
</tr>`).join('');
el.ridesTableBody.querySelectorAll('[data-ride]').forEach((btn) => {
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 ───────────────────────────────────────────────────────────
// Mirrors the Flutter admin app: a plain `admin` observes, a `super_admin`
// edits, approves and sees unmasked contact details.
@@ -812,6 +847,14 @@
{ 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',
subtitle: 'Geofenced demand density',
@@ -1121,6 +1164,35 @@
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).
async function runCryptoTool(action) {
if (!isSuperAdmin()) {
@@ -1180,6 +1252,8 @@
document.querySelectorAll('[data-action="decrypt"], [data-action="encrypt"]').forEach((btn) =>
btn.addEventListener('click', () => runCryptoTool(btn.dataset.action)));
$('waSendBtn')?.addEventListener('click', sendWhatsApp);
el.copyDiagnosticsBtn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(el.diagnosticsOutput.textContent);
@@ -1200,14 +1274,16 @@
['Captain', r.driver_full_name],
['Captain phone', maskPhone(r.d_phone)],
['Captain completed / cancelled', `${fmtInt(r.d_completed)} / ${fmtInt(r.d_canceled)}`],
['Pickup', r.address_start],
['Drop-off', r.address_end],
['Pickup', r.address_start || r.start_location],
['Drop-off', r.address_end || r.end_location],
['Distance', r.distance ? `${fmtNum(r.distance)} km` : '—'],
['Passenger fare', fmtMoney(r.price_for_passenger)],
['Captain earning', fmtMoney(r.price_for_driver)],
['Passenger fare', fmtMoney(rideFare(r))],
['Captain earning', r.price_for_driver ? fmtMoney(r.price_for_driver) : '—'],
['Payment method', r.paymentMethod],
['Service', r.carType],
['Started', fmtDate(r.rideTimeStart)],
['Finished', fmtDate(r.rideTimeFinish)],
['Requested', rideTimestamp(r)],
['Started', r.rideTimeStart ? fmtDate(r.rideTimeStart) : '—'],
['Finished', r.rideTimeFinish ? fmtDate(r.rideTimeFinish) : '—'],
['Cancellation note', r.cancel_reason],
];
body.innerHTML = `
@@ -1514,24 +1590,38 @@
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) {
const map = {
Finished: 'Completed', Begin: 'In progress', Apply: 'Captain assigned',
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';
if (!status) return 'Unknown';
return STATUS_LABELS[String(status).toLowerCase()] || humanize(status);
}
function badgeClass(status) {
const s = String(status || '').toLowerCase();
if (['finished', 'active', 'approved', 'online'].includes(s)) return 'badge-success';
if (s.startsWith('cancel') || ['timeout', 'suspended', 'rejected', 'blocked'].includes(s)) return 'badge-danger';
if (['begin', 'apply', 'applied', 'arrived'].includes(s)) return 'badge-primary';
if (['pending', 'new', 'wait', 'waiting', 'nothing'].includes(s)) return 'badge-warning';
if (['finished', 'completed', 'active', 'approved', 'online', 'success'].includes(s)) return 'badge-success';
if (s.startsWith('cancel') || ['timeout', 'refused', 'suspended', 'rejected', 'blocked', 'failure', 'error'].includes(s)) return 'badge-danger';
if (['begin', 'apply', 'applied', 'accepted', 'claimed', 'started', 'arrived'].includes(s)) return 'badge-primary';
if (['pending', 'pending_review', 'new', 'wait', 'waiting', 'nothing', 'searching'].includes(s)) return 'badge-warning';
return 'badge-info';
}