Update: 2026-07-25 01:38:11
This commit is contained in:
@@ -1181,3 +1181,34 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
color: var(--text-subtle);
|
color: var(--text-subtle);
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Inline search + clickable rows */
|
||||||
|
.search-inline {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-inline .form-input {
|
||||||
|
padding-left: 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-clickable { cursor: pointer; }
|
||||||
|
.row-clickable:hover { background: rgba(99, 102, 241, 0.08) !important; }
|
||||||
|
|
||||||
|
.load-more {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decrypt-area {
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 84px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -286,6 +286,10 @@
|
|||||||
<option value="Completed">Completed</option>
|
<option value="Completed">Completed</option>
|
||||||
<option value="Canceled">Cancelled</option>
|
<option value="Canceled">Cancelled</option>
|
||||||
</select>
|
</select>
|
||||||
|
<div class="search-inline">
|
||||||
|
<input type="text" class="form-input" id="ridesSearch" placeholder="Search rides by phone number">
|
||||||
|
<button class="btn btn-secondary btn-sm" id="ridesSearchBtn"><i class="ph ph-magnifying-glass"></i></button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="stamp" id="ridesMeta">—</span>
|
<span class="stamp" id="ridesMeta">—</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -308,6 +312,11 @@
|
|||||||
<tbody id="ridesTableBody"></tbody>
|
<tbody id="ridesTableBody"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="load-more">
|
||||||
|
<button class="btn btn-secondary btn-sm" id="ridesMore" hidden>
|
||||||
|
<i class="ph ph-plus"></i> Show more
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -325,6 +334,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-bar">
|
||||||
|
<div class="search-inline">
|
||||||
|
<input type="text" class="form-input" id="driversSearch" placeholder="Search by captain ID, phone or email">
|
||||||
|
<button class="btn btn-secondary btn-sm" id="driversSearchBtn"><i class="ph ph-magnifying-glass"></i></button>
|
||||||
|
</div>
|
||||||
|
<span class="stamp">Click any row to open the full profile</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
@@ -355,6 +372,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-bar">
|
||||||
|
<div class="search-inline">
|
||||||
|
<input type="text" class="form-input" id="passengersSearch" placeholder="Search by passenger ID, phone or email">
|
||||||
|
<button class="btn btn-secondary btn-sm" id="passengersSearchBtn"><i class="ph ph-magnifying-glass"></i></button>
|
||||||
|
</div>
|
||||||
|
<span class="stamp">Click any row to open the full profile</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
@@ -474,6 +499,27 @@
|
|||||||
<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">Encryption tool <span class="card-sub">super admin</span></h3>
|
||||||
|
</div>
|
||||||
|
<p class="card-note">
|
||||||
|
Decrypts a value taken straight from the database (or encrypts one for a manual query).
|
||||||
|
The server authorises this by admin phone number against its own allow-list.
|
||||||
|
</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="text" class="form-input" id="decryptPhone" placeholder="Your admin phone (as registered on the server)" style="padding-left:1rem;">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<textarea class="form-input decrypt-area" id="decryptInput" rows="3" placeholder="Paste the encrypted (or plain) value here"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="api-base-row">
|
||||||
|
<button class="btn btn-secondary btn-sm" id="decryptBtn" data-action="decrypt"><i class="ph ph-lock-open"></i> <span>Decrypt</span></button>
|
||||||
|
<button class="btn btn-secondary btn-sm" id="encryptBtn" data-action="encrypt"><i class="ph ph-lock"></i> Encrypt</button>
|
||||||
|
</div>
|
||||||
|
<pre class="diagnostics" id="decryptOutput" style="margin-top:1rem;">Result appears here.</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3 class="card-title">API endpoint</h3>
|
<h3 class="card-title">API endpoint</h3>
|
||||||
|
|||||||
+247
-22
@@ -31,6 +31,9 @@
|
|||||||
let stats = null; // latest row from dashbord.php
|
let stats = null; // latest row from dashbord.php
|
||||||
let driversPage = 1;
|
let driversPage = 1;
|
||||||
let driversPages = 1;
|
let driversPages = 1;
|
||||||
|
let allRides = [];
|
||||||
|
let ridesShown = 0;
|
||||||
|
const RIDES_PAGE_SIZE = 25;
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
const el = {};
|
const el = {};
|
||||||
@@ -67,6 +70,10 @@
|
|||||||
'ridesTableBody', 'ridesMeta', 'rideStatusFilter',
|
'ridesTableBody', 'ridesMeta', 'rideStatusFilter',
|
||||||
'driversTableBody', 'driversMeta', 'driversPrev', 'driversNext',
|
'driversTableBody', 'driversMeta', 'driversPrev', 'driversNext',
|
||||||
'passengersTableBody', 'approvalsTableBody',
|
'passengersTableBody', 'approvalsTableBody',
|
||||||
|
'ridesSearch', 'ridesSearchBtn', 'ridesMore',
|
||||||
|
'driversSearch', 'driversSearchBtn',
|
||||||
|
'passengersSearch', 'passengersSearchBtn',
|
||||||
|
'decryptInput', 'decryptBtn', 'decryptOutput',
|
||||||
'statusLegend', 'serviceMix', 'tripPerformance', 'sessionInfo',
|
'statusLegend', 'serviceMix', 'tripPerformance', 'sessionInfo',
|
||||||
'apiBaseSelect', 'apiBaseCustom', 'saveApiBaseBtn', 'runDiagnosticsBtn',
|
'apiBaseSelect', 'apiBaseCustom', 'saveApiBaseBtn', 'runDiagnosticsBtn',
|
||||||
'copyDiagnosticsBtn', 'diagnosticsOutput',
|
'copyDiagnosticsBtn', 'diagnosticsOutput',
|
||||||
@@ -125,11 +132,27 @@
|
|||||||
// ── API layer ────────────────────────────────────────────────────────────
|
// ── API layer ────────────────────────────────────────────────────────────
|
||||||
// Every protected endpoint goes through connect.php → JwtService::authenticate,
|
// Every protected endpoint goes through connect.php → JwtService::authenticate,
|
||||||
// which requires BOTH the bearer token and the X-Device-FP header.
|
// which requires BOTH the bearer token and the X-Device-FP header.
|
||||||
async function api(path, { method = 'GET', body = null, auth = true } = {}) {
|
// NOTE: the backend's filterRequest() reads POST and JSON bodies only — it
|
||||||
|
// never looks at $_GET. Any parameter sent as a query string is silently
|
||||||
|
// dropped and the endpoint falls back to its default (which is why the ride
|
||||||
|
// status filter always returned "Begin" and captain paging never advanced).
|
||||||
|
// Every parameter therefore goes in a POST body.
|
||||||
|
async function api(path, { method = null, body = null, params = null, auth = true } = {}) {
|
||||||
const headers = { 'X-Device-FP': deviceFingerprint };
|
const headers = { 'X-Device-FP': deviceFingerprint };
|
||||||
if (auth && session?.jwt) headers.Authorization = `Bearer ${session.jwt}`;
|
if (auth && session?.jwt) headers.Authorization = `Bearer ${session.jwt}`;
|
||||||
|
|
||||||
const res = await fetch(API_BASE + path, { method, headers, body });
|
if (params && !body) {
|
||||||
|
body = new FormData();
|
||||||
|
Object.entries(params).forEach(([k, v]) => {
|
||||||
|
if (v !== null && v !== undefined && v !== '') body.append(k, v);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(API_BASE + path, {
|
||||||
|
method: method || (body ? 'POST' : 'GET'),
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
});
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
|
|
||||||
let json;
|
let json;
|
||||||
@@ -295,6 +318,7 @@
|
|||||||
el.userName.textContent = session.name;
|
el.userName.textContent = session.name;
|
||||||
el.userRole.textContent = formatRole(session.role);
|
el.userRole.textContent = formatRole(session.role);
|
||||||
el.userAvatar.textContent = initials(session.name);
|
el.userAvatar.textContent = initials(session.name);
|
||||||
|
applyRoleVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Data loading ─────────────────────────────────────────────────────────
|
// ── Data loading ─────────────────────────────────────────────────────────
|
||||||
@@ -336,12 +360,17 @@
|
|||||||
async function loadRides() {
|
async function loadRides() {
|
||||||
const status = el.rideStatusFilter?.value || 'All';
|
const status = el.rideStatusFilter?.value || 'All';
|
||||||
tableMessage(el.ridesTableBody, 8, 'Loading rides…');
|
tableMessage(el.ridesTableBody, 8, 'Loading rides…');
|
||||||
|
if (el.ridesMore) el.ridesMore.hidden = true;
|
||||||
try {
|
try {
|
||||||
const rides = await api(`/Admin/rides/get_rides_by_status.php?status=${encodeURIComponent(status)}`);
|
const rides = await api('/Admin/rides/get_rides_by_status.php', { params: { status } });
|
||||||
renderRides(Array.isArray(rides) ? rides : []);
|
allRides = Array.isArray(rides) ? rides : [];
|
||||||
|
ridesShown = RIDES_PAGE_SIZE;
|
||||||
|
renderRides();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!handleApiError(err, 'rides')) tableMessage(el.ridesTableBody, 8, err.message, true);
|
if (!handleApiError(err, 'rides')) tableMessage(el.ridesTableBody, 8, err.message, true);
|
||||||
el.ridesMeta.textContent = '—';
|
el.ridesMeta.textContent = '—';
|
||||||
|
allRides = [];
|
||||||
|
if (el.ridesMore) el.ridesMore.hidden = true;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,7 +378,7 @@
|
|||||||
async function loadDrivers() {
|
async function loadDrivers() {
|
||||||
tableMessage(el.driversTableBody, 8, 'Loading captains…');
|
tableMessage(el.driversTableBody, 8, 'Loading captains…');
|
||||||
try {
|
try {
|
||||||
const payload = await api(`/Admin/AdminCaptain/get.php?page=${driversPage}`);
|
const payload = await api('/Admin/AdminCaptain/get.php', { params: { page: driversPage } });
|
||||||
driversPages = payload.pages || 1;
|
driversPages = payload.pages || 1;
|
||||||
renderDrivers(payload.data || [], payload.total || 0);
|
renderDrivers(payload.data || [], payload.total || 0);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -465,8 +494,13 @@
|
|||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRides(rides) {
|
function renderRides() {
|
||||||
el.ridesMeta.textContent = `${rides.length} trip${rides.length === 1 ? '' : 's'}`;
|
const rides = allRides.slice(0, ridesShown);
|
||||||
|
el.ridesMeta.textContent = allRides.length
|
||||||
|
? `Showing ${rides.length} of ${allRides.length} trip${allRides.length === 1 ? '' : 's'}`
|
||||||
|
: '—';
|
||||||
|
el.ridesMore.hidden = ridesShown >= allRides.length;
|
||||||
|
|
||||||
if (!rides.length) {
|
if (!rides.length) {
|
||||||
tableMessage(el.ridesTableBody, 8, 'No rides match this filter.');
|
tableMessage(el.ridesTableBody, 8, 'No rides match this filter.');
|
||||||
return;
|
return;
|
||||||
@@ -488,12 +522,134 @@
|
|||||||
|
|
||||||
el.ridesTableBody.querySelectorAll('[data-ride]').forEach((btn) => {
|
el.ridesTableBody.querySelectorAll('[data-ride]').forEach((btn) => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
const ride = rides.find((r) => String(r.id) === btn.dataset.ride);
|
const ride = allRides.find((r) => String(r.id) === btn.dataset.ride);
|
||||||
if (ride) showRideDetails(ride);
|
if (ride) showRideDetails(ride);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Role model ───────────────────────────────────────────────────────────
|
||||||
|
// Mirrors the Flutter admin app: a plain `admin` observes, a `super_admin`
|
||||||
|
// edits, approves and sees unmasked contact details.
|
||||||
|
const isSuperAdmin = () => session?.role === 'super_admin';
|
||||||
|
|
||||||
|
function maskPhone(phone) {
|
||||||
|
if (!phone || phone === '—') return '—';
|
||||||
|
if (isSuperAdmin()) return String(phone);
|
||||||
|
const s = String(phone);
|
||||||
|
return s.length > 6 ? `${s.slice(0, 4)}****${s.slice(-2)}` : '****';
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRoleVisibility() {
|
||||||
|
document.querySelectorAll('[data-requires-super]').forEach((node) => {
|
||||||
|
node.hidden = !isSuperAdmin();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lookup by phone / id ─────────────────────────────────────────────────
|
||||||
|
async function lookupRidesByPhone(phone) {
|
||||||
|
tableMessage(el.ridesTableBody, 8, `Searching rides for ${phone}…`);
|
||||||
|
try {
|
||||||
|
const payload = await api('/Admin/rides/admin_get_rides_by_phone.php', { params: { phone } });
|
||||||
|
const rows = Array.isArray(payload) ? payload : (payload?.rides || payload?.data || []);
|
||||||
|
allRides = rows;
|
||||||
|
ridesShown = RIDES_PAGE_SIZE;
|
||||||
|
renderRides();
|
||||||
|
if (!rows.length) tableMessage(el.ridesTableBody, 8, `No rides found for ${phone}.`);
|
||||||
|
} catch (err) {
|
||||||
|
if (!handleApiError(err, 'ride-lookup')) tableMessage(el.ridesTableBody, 8, err.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lookupCaptain(term) {
|
||||||
|
tableMessage(el.driversTableBody, 8, `Searching captains for ${term}…`);
|
||||||
|
const params = /^\d+$/.test(term) && term.length < 8
|
||||||
|
? { driver_id: term }
|
||||||
|
: (term.includes('@') ? { driverEmail: term } : { driverPhone: term });
|
||||||
|
try {
|
||||||
|
const payload = await api('/Admin/AdminCaptain/getCaptainDetailsByEmailOrIDOrPhone.php', { params });
|
||||||
|
const rows = normaliseRows(payload);
|
||||||
|
renderDrivers(rows, rows.length);
|
||||||
|
el.driversMeta.textContent = `Search results for “${term}”`;
|
||||||
|
if (!rows.length) tableMessage(el.driversTableBody, 8, `No captain matches “${term}”.`);
|
||||||
|
} catch (err) {
|
||||||
|
if (!handleApiError(err, 'captain-lookup')) tableMessage(el.driversTableBody, 8, err.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lookupPassenger(term) {
|
||||||
|
tableMessage(el.passengersTableBody, 8, `Searching passengers for ${term}…`);
|
||||||
|
const params = /^\d+$/.test(term) && term.length < 8
|
||||||
|
? { passengerId: term }
|
||||||
|
: (term.includes('@') ? { passengerEmail: term } : { passengerphone: term });
|
||||||
|
try {
|
||||||
|
const payload = await api('/Admin/getPassengerbyEmail.php', { params });
|
||||||
|
const rows = normaliseRows(payload);
|
||||||
|
renderPassengers(rows);
|
||||||
|
if (!rows.length) tableMessage(el.passengersTableBody, 8, `No passenger matches “${term}”.`);
|
||||||
|
} catch (err) {
|
||||||
|
if (!handleApiError(err, 'passenger-lookup')) tableMessage(el.passengersTableBody, 8, err.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normaliseRows(payload) {
|
||||||
|
if (Array.isArray(payload)) return payload;
|
||||||
|
if (payload && typeof payload === 'object') {
|
||||||
|
if (Array.isArray(payload.data)) return payload.data;
|
||||||
|
return [payload];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Profile drawers ──────────────────────────────────────────────────────
|
||||||
|
async function openCaptainProfile(id) {
|
||||||
|
openProfile(`Captain #${id}`, () =>
|
||||||
|
api('/Admin/AdminCaptain/getCaptainDetailsById.php', { params: { driver_id: id } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openPassengerProfile(id) {
|
||||||
|
openProfile(`Passenger #${id}`, () =>
|
||||||
|
api('/Admin/getPassengerDetailsByPassengerID.php', { params: { passengerID: id } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openProfile(title, fetcher) {
|
||||||
|
const body = $('modalBodyContent');
|
||||||
|
body.innerHTML = `
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>${esc(title)}</h3>
|
||||||
|
<button class="btn-icon" onclick="closeModal()"><i class="ph ph-x"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="table-msg">Loading profile…</div>`;
|
||||||
|
$('detailsModal').classList.add('active');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await fetcher();
|
||||||
|
const record = normaliseRows(payload)[0];
|
||||||
|
if (!record) throw new ApiError('No profile returned for this record.', 0);
|
||||||
|
|
||||||
|
const entries = Object.entries(record)
|
||||||
|
.filter(([k]) => !/token|password|fingerprint/i.test(k))
|
||||||
|
.map(([k, v]) => [humanize(k), /phone/i.test(k) ? maskPhone(v) : formatValue(v, k)]);
|
||||||
|
|
||||||
|
body.innerHTML = `
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>${esc(title)}</h3>
|
||||||
|
<button class="btn-icon" onclick="closeModal()"><i class="ph ph-x"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="mini-list">
|
||||||
|
${entries.map(([k, v]) => `<div class="kv-row"><span>${esc(k)}</span><strong>${esc(String(v))}</strong></div>`).join('')}
|
||||||
|
</div>`;
|
||||||
|
} catch (err) {
|
||||||
|
if (handleApiError(err, 'profile')) return;
|
||||||
|
body.innerHTML = `
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>${esc(title)}</h3>
|
||||||
|
<button class="btn-icon" onclick="closeModal()"><i class="ph ph-x"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="table-msg is-error">${esc(err.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderDrivers(drivers, total) {
|
function renderDrivers(drivers, total) {
|
||||||
el.driversMeta.textContent = `Page ${driversPage} of ${driversPages} · ${fmtInt(total)} captains`;
|
el.driversMeta.textContent = `Page ${driversPage} of ${driversPages} · ${fmtInt(total)} captains`;
|
||||||
if (!drivers.length) {
|
if (!drivers.length) {
|
||||||
@@ -501,10 +657,10 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
el.driversTableBody.innerHTML = drivers.map((d) => `
|
el.driversTableBody.innerHTML = drivers.map((d) => `
|
||||||
<tr>
|
<tr class="row-clickable" data-captain="${esc(d.id)}">
|
||||||
<td><strong>#${esc(d.id)}</strong></td>
|
<td><strong>#${esc(d.id)}</strong></td>
|
||||||
<td>${esc(`${d.first_name || ''} ${d.last_name || ''}`.trim() || 'Unnamed')}</td>
|
<td>${esc(`${d.first_name || ''} ${d.last_name || ''}`.trim() || 'Unnamed')}</td>
|
||||||
<td>${esc(d.phone || '—')}</td>
|
<td>${esc(maskPhone(d.phone))}</td>
|
||||||
<td>${esc(d.email || '—')}</td>
|
<td>${esc(d.email || '—')}</td>
|
||||||
<td>${rating(d.passengerAverageRating)}</td>
|
<td>${rating(d.passengerAverageRating)}</td>
|
||||||
<td>${fmtInt(d.countPassengerRide)}</td>
|
<td>${fmtInt(d.countPassengerRide)}</td>
|
||||||
@@ -512,6 +668,9 @@
|
|||||||
<td><span class="badge ${badgeClass(d.status)}">${esc(d.status || 'unknown')}</span></td>
|
<td><span class="badge ${badgeClass(d.status)}">${esc(d.status || 'unknown')}</span></td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
||||||
|
el.driversTableBody.querySelectorAll('[data-captain]').forEach((row) =>
|
||||||
|
row.addEventListener('click', () => openCaptainProfile(row.dataset.captain)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPassengers(rows) {
|
function renderPassengers(rows) {
|
||||||
@@ -520,10 +679,10 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
el.passengersTableBody.innerHTML = rows.map((p) => `
|
el.passengersTableBody.innerHTML = rows.map((p) => `
|
||||||
<tr>
|
<tr class="row-clickable" data-passenger="${esc(p.id)}">
|
||||||
<td><strong>#${esc(p.id)}</strong></td>
|
<td><strong>#${esc(p.id)}</strong></td>
|
||||||
<td>${esc(`${p.first_name || ''} ${p.last_name || ''}`.trim() || 'Unnamed')}</td>
|
<td>${esc(`${p.first_name || ''} ${p.last_name || ''}`.trim() || 'Unnamed')}</td>
|
||||||
<td>${esc(p.email || p.phone || '—')}</td>
|
<td>${esc(p.email || maskPhone(p.phone))}</td>
|
||||||
<td>${fmtInt(p.countPassengerRide)}</td>
|
<td>${fmtInt(p.countPassengerRide)}</td>
|
||||||
<td>${rating(p.passengerAverageRating)}</td>
|
<td>${rating(p.passengerAverageRating)}</td>
|
||||||
<td>${fmtInt(p.countPassengerCancel)}</td>
|
<td>${fmtInt(p.countPassengerCancel)}</td>
|
||||||
@@ -531,6 +690,9 @@
|
|||||||
<td><span class="badge ${badgeClass(p.status)}">${esc(p.status || 'unknown')}</span></td>
|
<td><span class="badge ${badgeClass(p.status)}">${esc(p.status || 'unknown')}</span></td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
||||||
|
el.passengersTableBody.querySelectorAll('[data-passenger]').forEach((row) =>
|
||||||
|
row.addEventListener('click', () => openPassengerProfile(row.dataset.passenger)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderApprovals(pending) {
|
function renderApprovals(pending) {
|
||||||
@@ -658,7 +820,7 @@
|
|||||||
{
|
{
|
||||||
id: 'quality', group: 'Quality', icon: 'ph-prohibit', title: 'Blacklist',
|
id: 'quality', group: 'Quality', icon: 'ph-prohibit', title: 'Blacklist',
|
||||||
subtitle: 'Blocked captains and passengers',
|
subtitle: 'Blocked captains and passengers',
|
||||||
panels: [{ title: 'Blacklist', path: '/Admin/v2/quality/blacklist_manager.php?action_type=get_all' }],
|
panels: [{ title: 'Blacklist', path: '/Admin/v2/quality/blacklist_manager.php', params: { action_type: 'get_all' } }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'scorecard', group: 'Quality', icon: 'ph-medal', title: 'Driver Scorecard',
|
id: 'scorecard', group: 'Quality', icon: 'ph-medal', title: 'Driver Scorecard',
|
||||||
@@ -674,7 +836,7 @@
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'staff', group: 'Administration', icon: 'ph-identification-badge', title: 'Staff & Employees',
|
id: 'staff', superOnly: true, group: 'Administration', icon: 'ph-identification-badge', title: 'Staff & Employees',
|
||||||
subtitle: 'Internal staff records',
|
subtitle: 'Internal staff records',
|
||||||
panels: [{ title: 'Employees', path: '/Admin/employee/get.php' }],
|
panels: [{ title: 'Employees', path: '/Admin/employee/get.php' }],
|
||||||
},
|
},
|
||||||
@@ -709,6 +871,10 @@
|
|||||||
item.className = 'nav-item';
|
item.className = 'nav-item';
|
||||||
item.dataset.view = `mod_${mod.id}`;
|
item.dataset.view = `mod_${mod.id}`;
|
||||||
item.dataset.module = mod.id;
|
item.dataset.module = mod.id;
|
||||||
|
if (mod.superOnly) {
|
||||||
|
item.setAttribute('data-requires-super', '');
|
||||||
|
item.hidden = true;
|
||||||
|
}
|
||||||
item.innerHTML = `<i class="ph ${mod.icon}"></i><span>${esc(mod.title)}</span>`;
|
item.innerHTML = `<i class="ph ${mod.icon}"></i><span>${esc(mod.title)}</span>`;
|
||||||
menu.appendChild(item);
|
menu.appendChild(item);
|
||||||
|
|
||||||
@@ -749,7 +915,7 @@
|
|||||||
await Promise.all(mod.panels.map(async (p) => {
|
await Promise.all(mod.panels.map(async (p) => {
|
||||||
const body = host.querySelector(`[data-panel="${cssEscape(p.path)}"] .panel-body`);
|
const body = host.querySelector(`[data-panel="${cssEscape(p.path)}"] .panel-body`);
|
||||||
try {
|
try {
|
||||||
const payload = await api(p.path);
|
const payload = await api(p.path, { params: p.params || null });
|
||||||
renderPayload(body, payload);
|
renderPayload(body, payload);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (handleApiError(err, mod.id)) return;
|
if (handleApiError(err, mod.id)) return;
|
||||||
@@ -773,12 +939,14 @@
|
|||||||
const frag = document.createDocumentFragment();
|
const frag = document.createDocumentFragment();
|
||||||
|
|
||||||
if (payload === null || payload === undefined || payload === '') {
|
if (payload === null || payload === undefined || payload === '') {
|
||||||
frag.appendChild(msgNode('No data returned.'));
|
frag.appendChild(msgNode('The endpoint responded successfully but returned no data yet.'));
|
||||||
return frag;
|
return frag;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(payload)) {
|
if (Array.isArray(payload)) {
|
||||||
frag.appendChild(payload.length ? buildTable(payload) : msgNode('No records.'));
|
frag.appendChild(payload.length
|
||||||
|
? buildTable(payload)
|
||||||
|
: msgNode('No records recorded for this yet — the table is empty in the database.'));
|
||||||
return frag;
|
return frag;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -891,8 +1059,8 @@
|
|||||||
// guess: 404 = wrong API base, 403 = role/device rejection, 401 = token.
|
// guess: 404 = wrong API base, 403 = role/device rejection, 401 = token.
|
||||||
const PROBES = [
|
const PROBES = [
|
||||||
['Dashboard stats', '/Admin/dashbord.php'],
|
['Dashboard stats', '/Admin/dashbord.php'],
|
||||||
['Rides', '/Admin/rides/get_rides_by_status.php?status=All'],
|
['Rides', '/Admin/rides/get_rides_by_status.php', { status: 'All' }],
|
||||||
['Captains', '/Admin/AdminCaptain/get.php?page=1'],
|
['Captains', '/Admin/AdminCaptain/get.php', { page: 1 }],
|
||||||
['Passengers', '/Admin/getPassengerDetails.php'],
|
['Passengers', '/Admin/getPassengerDetails.php'],
|
||||||
['Pending approvals', '/Admin/Staff/pending.php'],
|
['Pending approvals', '/Admin/Staff/pending.php'],
|
||||||
['Rides per month', '/Admin/AdminRide/getRidesPerMonth.php'],
|
['Rides per month', '/Admin/AdminRide/getRidesPerMonth.php'],
|
||||||
@@ -912,11 +1080,18 @@
|
|||||||
'─'.repeat(64),
|
'─'.repeat(64),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const [name, path] of PROBES) {
|
for (const [name, path, params] of PROBES) {
|
||||||
const url = API_BASE + path;
|
const url = API_BASE + path;
|
||||||
const started = performance.now();
|
const started = performance.now();
|
||||||
|
let form = null;
|
||||||
|
if (params) {
|
||||||
|
form = new FormData();
|
||||||
|
Object.entries(params).forEach(([k, v]) => form.append(k, v));
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
|
method: form ? 'POST' : 'GET',
|
||||||
|
body: form,
|
||||||
headers: {
|
headers: {
|
||||||
'X-Device-FP': deviceFingerprint,
|
'X-Device-FP': deviceFingerprint,
|
||||||
...(session?.jwt ? { Authorization: `Bearer ${session.jwt}` } : {}),
|
...(session?.jwt ? { Authorization: `Bearer ${session.jwt}` } : {}),
|
||||||
@@ -946,6 +1121,30 @@
|
|||||||
busy(el.runDiagnosticsBtn, false, 'Run diagnostics');
|
busy(el.runDiagnosticsBtn, false, 'Run diagnostics');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Super-admin only: mirrors the Flutter EncryptToolPage (Admin/ggg.php).
|
||||||
|
async function runCryptoTool(action) {
|
||||||
|
if (!isSuperAdmin()) {
|
||||||
|
toast('This tool is restricted to super admins.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = el.decryptInput.value.trim();
|
||||||
|
const adminPhone = $('decryptPhone').value.trim();
|
||||||
|
if (!text || !adminPhone) {
|
||||||
|
toast('Enter both your admin phone and the value.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.decryptOutput.textContent = 'Working…';
|
||||||
|
try {
|
||||||
|
const payload = await api('/Admin/ggg.php', {
|
||||||
|
params: { action, text, admin_phone: adminPhone },
|
||||||
|
});
|
||||||
|
el.decryptOutput.textContent = payload?.result ?? JSON.stringify(payload, null, 2);
|
||||||
|
} catch (err) {
|
||||||
|
el.decryptOutput.textContent = `Failed: ${err.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function collapse(text) {
|
function collapse(text) {
|
||||||
return String(text).replace(/\s+/g, ' ').trim() || '(empty response body)';
|
return String(text).replace(/\s+/g, ' ').trim() || '(empty response body)';
|
||||||
}
|
}
|
||||||
@@ -978,6 +1177,9 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
el.runDiagnosticsBtn.addEventListener('click', runDiagnostics);
|
el.runDiagnosticsBtn.addEventListener('click', runDiagnostics);
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-action="decrypt"], [data-action="encrypt"]').forEach((btn) =>
|
||||||
|
btn.addEventListener('click', () => runCryptoTool(btn.dataset.action)));
|
||||||
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);
|
||||||
@@ -993,10 +1195,10 @@
|
|||||||
const rows = [
|
const rows = [
|
||||||
['Status', labelStatus(r.status)],
|
['Status', labelStatus(r.status)],
|
||||||
['Passenger', r.passenger_full_name],
|
['Passenger', r.passenger_full_name],
|
||||||
['Passenger phone', r.p_phone],
|
['Passenger phone', maskPhone(r.p_phone)],
|
||||||
['Completed trips (passenger)', fmtInt(r.p_completed)],
|
['Completed trips (passenger)', fmtInt(r.p_completed)],
|
||||||
['Captain', r.driver_full_name],
|
['Captain', r.driver_full_name],
|
||||||
['Captain phone', 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],
|
||||||
['Drop-off', r.address_end],
|
['Drop-off', r.address_end],
|
||||||
@@ -1223,6 +1425,17 @@
|
|||||||
if (driversPage < driversPages) { driversPage++; loadDrivers().catch(() => {}); }
|
if (driversPage < driversPages) { driversPage++; loadDrivers().catch(() => {}); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
el.ridesMore?.addEventListener('click', () => {
|
||||||
|
ridesShown += RIDES_PAGE_SIZE;
|
||||||
|
renderRides();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Server-side lookups: these hit dedicated endpoints rather than filtering
|
||||||
|
// the page, so an operator can find a record that is not in the last batch.
|
||||||
|
bindSearch(el.ridesSearch, el.ridesSearchBtn, (term) => lookupRidesByPhone(term), () => loadRides());
|
||||||
|
bindSearch(el.driversSearch, el.driversSearchBtn, (term) => lookupCaptain(term), () => loadDrivers());
|
||||||
|
bindSearch(el.passengersSearch, el.passengersSearchBtn, (term) => lookupPassenger(term), () => loadPassengers());
|
||||||
|
|
||||||
el.globalSearch?.addEventListener('input', (e) => {
|
el.globalSearch?.addEventListener('input', (e) => {
|
||||||
const q = e.target.value.toLowerCase();
|
const q = e.target.value.toLowerCase();
|
||||||
const active = document.querySelector('.page-view.active');
|
const active = document.querySelector('.page-view.active');
|
||||||
@@ -1238,6 +1451,18 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bindSearch(input, button, onSearch, onClear) {
|
||||||
|
if (!input) return;
|
||||||
|
const run = () => {
|
||||||
|
const term = input.value.trim();
|
||||||
|
if (term) onSearch(term);
|
||||||
|
else onClear();
|
||||||
|
};
|
||||||
|
button?.addEventListener('click', run);
|
||||||
|
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') run(); });
|
||||||
|
input.addEventListener('input', () => { if (!input.value.trim()) onClear(); });
|
||||||
|
}
|
||||||
|
|
||||||
// ── Small helpers ────────────────────────────────────────────────────────
|
// ── Small helpers ────────────────────────────────────────────────────────
|
||||||
function setKpi(key, value) {
|
function setKpi(key, value) {
|
||||||
document.querySelectorAll(`[data-kpi="${key}"]`).forEach((n) => { n.textContent = value; });
|
document.querySelectorAll(`[data-kpi="${key}"]`).forEach((n) => { n.textContent = value; });
|
||||||
|
|||||||
Reference in New Issue
Block a user