Update: 2026-07-25 01:38:11

This commit is contained in:
Hamza-Ayed
2026-07-25 01:38:11 +03:00
parent 0d9095fd3f
commit 915a148ebf
3 changed files with 324 additions and 22 deletions
+31
View File
@@ -1181,3 +1181,34 @@ h1, h2, h3, h4, h5, h6 {
color: var(--text-subtle);
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;
}
+46
View File
@@ -286,6 +286,10 @@
<option value="Completed">Completed</option>
<option value="Canceled">Cancelled</option>
</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>
<span class="stamp" id="ridesMeta">—</span>
</div>
@@ -308,6 +312,11 @@
<tbody id="ridesTableBody"></tbody>
</table>
</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>
</section>
@@ -325,6 +334,14 @@
</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="table-responsive">
<table class="data-table">
@@ -355,6 +372,14 @@
</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="table-responsive">
<table class="data-table">
@@ -474,6 +499,27 @@
<div class="mini-list" id="sessionInfo"></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-header">
<h3 class="card-title">API endpoint</h3>
+247 -22
View File
@@ -31,6 +31,9 @@
let stats = null; // latest row from dashbord.php
let driversPage = 1;
let driversPages = 1;
let allRides = [];
let ridesShown = 0;
const RIDES_PAGE_SIZE = 25;
const $ = (id) => document.getElementById(id);
const el = {};
@@ -67,6 +70,10 @@
'ridesTableBody', 'ridesMeta', 'rideStatusFilter',
'driversTableBody', 'driversMeta', 'driversPrev', 'driversNext',
'passengersTableBody', 'approvalsTableBody',
'ridesSearch', 'ridesSearchBtn', 'ridesMore',
'driversSearch', 'driversSearchBtn',
'passengersSearch', 'passengersSearchBtn',
'decryptInput', 'decryptBtn', 'decryptOutput',
'statusLegend', 'serviceMix', 'tripPerformance', 'sessionInfo',
'apiBaseSelect', 'apiBaseCustom', 'saveApiBaseBtn', 'runDiagnosticsBtn',
'copyDiagnosticsBtn', 'diagnosticsOutput',
@@ -125,11 +132,27 @@
// ── API layer ────────────────────────────────────────────────────────────
// Every protected endpoint goes through connect.php → JwtService::authenticate,
// 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 };
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();
let json;
@@ -295,6 +318,7 @@
el.userName.textContent = session.name;
el.userRole.textContent = formatRole(session.role);
el.userAvatar.textContent = initials(session.name);
applyRoleVisibility();
}
// ── Data loading ─────────────────────────────────────────────────────────
@@ -336,12 +360,17 @@
async function loadRides() {
const status = el.rideStatusFilter?.value || 'All';
tableMessage(el.ridesTableBody, 8, 'Loading rides…');
if (el.ridesMore) el.ridesMore.hidden = true;
try {
const rides = await api(`/Admin/rides/get_rides_by_status.php?status=${encodeURIComponent(status)}`);
renderRides(Array.isArray(rides) ? rides : []);
const rides = await api('/Admin/rides/get_rides_by_status.php', { params: { status } });
allRides = Array.isArray(rides) ? rides : [];
ridesShown = RIDES_PAGE_SIZE;
renderRides();
} catch (err) {
if (!handleApiError(err, 'rides')) tableMessage(el.ridesTableBody, 8, err.message, true);
el.ridesMeta.textContent = '—';
allRides = [];
if (el.ridesMore) el.ridesMore.hidden = true;
throw err;
}
}
@@ -349,7 +378,7 @@
async function loadDrivers() {
tableMessage(el.driversTableBody, 8, 'Loading captains…');
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;
renderDrivers(payload.data || [], payload.total || 0);
} catch (err) {
@@ -465,8 +494,13 @@
`).join('');
}
function renderRides(rides) {
el.ridesMeta.textContent = `${rides.length} trip${rides.length === 1 ? '' : 's'}`;
function renderRides() {
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) {
tableMessage(el.ridesTableBody, 8, 'No rides match this filter.');
return;
@@ -488,12 +522,134 @@
el.ridesTableBody.querySelectorAll('[data-ride]').forEach((btn) => {
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);
});
});
}
// ── 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) {
el.driversMeta.textContent = `Page ${driversPage} of ${driversPages} · ${fmtInt(total)} captains`;
if (!drivers.length) {
@@ -501,10 +657,10 @@
return;
}
el.driversTableBody.innerHTML = drivers.map((d) => `
<tr>
<tr class="row-clickable" data-captain="${esc(d.id)}">
<td><strong>#${esc(d.id)}</strong></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>${rating(d.passengerAverageRating)}</td>
<td>${fmtInt(d.countPassengerRide)}</td>
@@ -512,6 +668,9 @@
<td><span class="badge ${badgeClass(d.status)}">${esc(d.status || 'unknown')}</span></td>
</tr>
`).join('');
el.driversTableBody.querySelectorAll('[data-captain]').forEach((row) =>
row.addEventListener('click', () => openCaptainProfile(row.dataset.captain)));
}
function renderPassengers(rows) {
@@ -520,10 +679,10 @@
return;
}
el.passengersTableBody.innerHTML = rows.map((p) => `
<tr>
<tr class="row-clickable" data-passenger="${esc(p.id)}">
<td><strong>#${esc(p.id)}</strong></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>${rating(p.passengerAverageRating)}</td>
<td>${fmtInt(p.countPassengerCancel)}</td>
@@ -531,6 +690,9 @@
<td><span class="badge ${badgeClass(p.status)}">${esc(p.status || 'unknown')}</span></td>
</tr>
`).join('');
el.passengersTableBody.querySelectorAll('[data-passenger]').forEach((row) =>
row.addEventListener('click', () => openPassengerProfile(row.dataset.passenger)));
}
function renderApprovals(pending) {
@@ -658,7 +820,7 @@
{
id: 'quality', group: 'Quality', icon: 'ph-prohibit', title: 'Blacklist',
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',
@@ -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',
panels: [{ title: 'Employees', path: '/Admin/employee/get.php' }],
},
@@ -709,6 +871,10 @@
item.className = 'nav-item';
item.dataset.view = `mod_${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>`;
menu.appendChild(item);
@@ -749,7 +915,7 @@
await Promise.all(mod.panels.map(async (p) => {
const body = host.querySelector(`[data-panel="${cssEscape(p.path)}"] .panel-body`);
try {
const payload = await api(p.path);
const payload = await api(p.path, { params: p.params || null });
renderPayload(body, payload);
} catch (err) {
if (handleApiError(err, mod.id)) return;
@@ -773,12 +939,14 @@
const frag = document.createDocumentFragment();
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;
}
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;
}
@@ -891,8 +1059,8 @@
// guess: 404 = wrong API base, 403 = role/device rejection, 401 = token.
const PROBES = [
['Dashboard stats', '/Admin/dashbord.php'],
['Rides', '/Admin/rides/get_rides_by_status.php?status=All'],
['Captains', '/Admin/AdminCaptain/get.php?page=1'],
['Rides', '/Admin/rides/get_rides_by_status.php', { status: 'All' }],
['Captains', '/Admin/AdminCaptain/get.php', { page: 1 }],
['Passengers', '/Admin/getPassengerDetails.php'],
['Pending approvals', '/Admin/Staff/pending.php'],
['Rides per month', '/Admin/AdminRide/getRidesPerMonth.php'],
@@ -912,11 +1080,18 @@
'─'.repeat(64),
];
for (const [name, path] of PROBES) {
for (const [name, path, params] of PROBES) {
const url = API_BASE + path;
const started = performance.now();
let form = null;
if (params) {
form = new FormData();
Object.entries(params).forEach(([k, v]) => form.append(k, v));
}
try {
const res = await fetch(url, {
method: form ? 'POST' : 'GET',
body: form,
headers: {
'X-Device-FP': deviceFingerprint,
...(session?.jwt ? { Authorization: `Bearer ${session.jwt}` } : {}),
@@ -946,6 +1121,30 @@
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) {
return String(text).replace(/\s+/g, ' ').trim() || '(empty response body)';
}
@@ -978,6 +1177,9 @@
});
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 () => {
try {
await navigator.clipboard.writeText(el.diagnosticsOutput.textContent);
@@ -993,10 +1195,10 @@
const rows = [
['Status', labelStatus(r.status)],
['Passenger', r.passenger_full_name],
['Passenger phone', r.p_phone],
['Passenger phone', maskPhone(r.p_phone)],
['Completed trips (passenger)', fmtInt(r.p_completed)],
['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)}`],
['Pickup', r.address_start],
['Drop-off', r.address_end],
@@ -1223,6 +1425,17 @@
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) => {
const q = e.target.value.toLowerCase();
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 ────────────────────────────────────────────────────────
function setKpi(key, value) {
document.querySelectorAll(`[data-kpi="${key}"]`).forEach((n) => { n.textContent = value; });