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
@@ -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); }
|
||||
|
||||
@@ -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
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user