Files
Siro/dashboard/siro-admin/js/app.js
T
Hamza-AyedandClaude Opus 5 852c6ece5c 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>
2026-07-25 01:47:55 +03:00

1653 lines
65 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ==========================================================================
Siro Admin Console — application engine
All figures rendered here come from the production API. There is no
mock/demo dataset: when a request fails the table says so instead of
showing invented numbers.
========================================================================== */
(() => {
'use strict';
const SESSION_KEY = 'siro_admin_user';
const FP_KEY = 'siro_web_fp';
const API_BASE_KEY = 'siro_api_base';
// The Flutter admin app talks to a per-country host (see
// siro_admin/lib/constant/links.dart). The web console defaults to the
// backend deployed next to it, but the operator can repoint it.
const API_CANDIDATES = [
{ label: 'Same origin (/backend)', value: '/backend' },
{ label: 'Jordan — jordan-siro.intaleqapp.com', value: 'https://jordan-siro.intaleqapp.com/backend' },
{ label: 'Default — api.siromove.com', value: 'https://api.siromove.com/siro_v3' },
{ label: 'Syria — api-syria.siromove.com', value: 'https://api-syria.siromove.com/siro_v3' },
{ label: 'Egypt — api-egypt.siromove.com', value: 'https://api-egypt.siromove.com/siro_v3' },
];
let API_BASE = localStorage.getItem(API_BASE_KEY) || '/backend';
// ── Session state ────────────────────────────────────────────────────────
let session = null; // { id, name, role, jwt, issuedAt, expiresIn }
let deviceFingerprint = '';
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 = {};
document.addEventListener('DOMContentLoaded', init);
async function init() {
cacheElements();
deviceFingerprint = await resolveFingerprint();
if (el.fpPreview) el.fpPreview.textContent = deviceFingerprint.slice(0, 12) + '…';
buildModules();
setupNavigation();
setupAuthEvents();
setupDataEvents();
setupDiagnostics();
session = readSession();
if (session?.jwt) {
showConsole();
loadEverything();
} else {
showLogin();
}
}
function cacheElements() {
[
'authWrapper', 'loginForm', 'loginEmail', 'loginPass', 'loginSubmitBtn', 'fpPreview',
'otpModal', 'otpInput', 'otpPhoneText', 'submitOtpBtn',
'sidebar', 'toggleSidebar', 'logoutBtn', 'refreshBtn', 'globalSearch',
'connectionPill', 'connectionText', 'lastUpdated',
'userName', 'userRole', 'userAvatar', 'approvalsCount',
'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',
].forEach((id) => { el[id] = $(id); });
}
// ── Device fingerprint ───────────────────────────────────────────────────
// The backend binds every access token to this value (JwtService compares
// sha256(X-Device-FP + pepper) against the claim inside the JWT), so it must
// stay identical for the whole life of the browser profile.
async function resolveFingerprint() {
const cached = localStorage.getItem(FP_KEY);
if (cached) return cached;
const traits = [
navigator.userAgent,
navigator.platform || '',
(navigator.languages || [navigator.language]).join(','),
Intl.DateTimeFormat().resolvedOptions().timeZone || '',
`${screen.width}x${screen.height}x${screen.colorDepth}`,
String(navigator.hardwareConcurrency || 0),
String(navigator.maxTouchPoints || 0),
canvasSignature(),
].join('|');
let fp;
try {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(traits));
fp = 'web_' + [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
} catch {
// crypto.subtle is unavailable over plain HTTP — fall back to a random,
// still-persistent identifier.
fp = 'web_' + Math.random().toString(36).slice(2) + Date.now().toString(36);
}
localStorage.setItem(FP_KEY, fp);
return fp;
}
function canvasSignature() {
try {
const c = document.createElement('canvas');
c.width = 200; c.height = 40;
const ctx = c.getContext('2d');
ctx.textBaseline = 'top';
ctx.font = '14px Arial';
ctx.fillStyle = '#f60';
ctx.fillRect(0, 0, 100, 20);
ctx.fillStyle = '#069';
ctx.fillText('siro-admin', 2, 4);
return c.toDataURL().slice(-64);
} catch {
return 'no-canvas';
}
}
// ── API layer ────────────────────────────────────────────────────────────
// Every protected endpoint goes through connect.php → JwtService::authenticate,
// which requires BOTH the bearer token and the X-Device-FP header.
// 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}`;
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;
try {
json = JSON.parse(text);
} catch {
throw new ApiError(`Server returned a non-JSON response (HTTP ${res.status})`, res.status);
}
if (res.status === 401 || res.status === 403) {
throw new ApiError(pickMessage(json) || 'Session rejected by the server', res.status);
}
if (json.status === 'failure' || json.error) {
throw new ApiError(pickMessage(json) || 'Request failed', res.status);
}
// jsonSuccess()/printSuccess() put the payload in `message`; a few older
// endpoints use `data`.
return json.message !== undefined ? json.message : (json.data !== undefined ? json.data : json);
}
class ApiError extends Error {
constructor(message, status) { super(message); this.status = status; }
}
function pickMessage(json) {
if (!json) return null;
if (typeof json.message === 'string') return json.message;
if (typeof json.error === 'string') return json.error;
if (json.message && typeof json.message.message === 'string') return json.message.message;
return null;
}
function handleApiError(err, context) {
console.error(`[${context}]`, err);
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
signOut(`Session ended: ${err.message}`);
return true;
}
setConnection('error', 'Data unavailable');
return false;
}
// ── Authentication ───────────────────────────────────────────────────────
let pendingPhone = '';
function setupAuthEvents() {
el.loginForm?.addEventListener('submit', onLogin);
el.submitOtpBtn?.addEventListener('click', onVerifyOtp);
el.otpInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') onVerifyOtp(); });
el.logoutBtn?.addEventListener('click', () => signOut('You have been signed out.'));
window.closeOtpModal = () => el.otpModal?.classList.remove('active');
}
async function onLogin(e) {
e.preventDefault();
const phone = el.loginEmail.value.trim();
const password = el.loginPass.value;
if (!phone || !password) return;
busy(el.loginSubmitBtn, true, 'Verifying…');
try {
const form = new FormData();
form.append('phone', phone);
form.append('password', password);
form.append('fingerprint', deviceFingerprint);
form.append('aud', 'admin');
const payload = await api('/Admin/auth/login.php', { method: 'POST', body: form, auth: false });
if (payload?.status === 'otp_required') {
pendingPhone = phone;
el.otpPhoneText.textContent = `Enter the 3-digit code sent to ${payload.phone || 'your WhatsApp'}.`;
el.otpModal.classList.add('active');
el.otpInput.value = '';
el.otpInput.focus();
toast(payload.message || 'Verification code sent.', 'info');
} else if (payload?.jwt) {
// Trusted-device renewal path — no OTP required.
establishSession(payload);
} else {
toast('Unexpected response from the authentication service.', 'danger');
}
} catch (err) {
toast(err.message, 'danger');
} finally {
busy(el.loginSubmitBtn, false, 'Sign In');
}
}
async function onVerifyOtp() {
const otp = el.otpInput.value.trim();
if (otp.length < 3) {
toast('Enter the 3-digit code.', 'warning');
return;
}
busy(el.submitOtpBtn, true, 'Verifying…');
try {
const form = new FormData();
form.append('otp', otp);
form.append('fingerprint', deviceFingerprint);
form.append('aud', 'admin');
const payload = await api('/Admin/auth/verify_login.php', { method: 'POST', body: form, auth: false });
if (!payload?.jwt) throw new ApiError('No token issued.', 0);
el.otpModal.classList.remove('active');
establishSession(payload);
} catch (err) {
toast(err.message, 'danger');
} finally {
busy(el.submitOtpBtn, false, 'Verify & Sign In');
}
}
function establishSession(payload) {
const admin = payload.admin || {};
session = {
id: admin.id ?? null,
name: admin.name || 'Admin',
email: admin.email || pendingPhone,
role: admin.role || 'admin',
jwt: payload.jwt,
issuedAt: Date.now(),
expiresIn: Number(payload.expires_in) || 3600,
};
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
showConsole();
toast(`Welcome back, ${session.name}.`, 'success');
loadEverything();
}
function readSession() {
try {
const raw = JSON.parse(localStorage.getItem(SESSION_KEY) || 'null');
if (!raw?.jwt) return null;
// Access tokens live one hour; drop anything already expired locally so
// we show the login form rather than a wall of failed requests.
const age = (Date.now() - (raw.issuedAt || 0)) / 1000;
if (age >= (raw.expiresIn || 3600)) return null;
return raw;
} catch {
return null;
}
}
function signOut(message) {
localStorage.removeItem(SESSION_KEY);
session = null;
stats = null;
loadedModules.clear();
showLogin();
if (message) toast(message, 'info');
}
function showLogin() {
el.authWrapper.classList.remove('hidden');
el.loginPass.value = '';
}
function showConsole() {
el.authWrapper.classList.add('hidden');
el.userName.textContent = session.name;
el.userRole.textContent = formatRole(session.role);
el.userAvatar.textContent = initials(session.name);
applyRoleVisibility();
}
// ── Data loading ─────────────────────────────────────────────────────────
function loadEverything() {
setConnection('loading', 'Loading live data…');
Promise.allSettled([
loadStats(),
loadRides(),
loadDrivers(),
loadPassengers(),
loadApprovals(),
loadRidesTrend(),
]).then((results) => {
renderSessionInfo();
const ok = results.some((r) => r.status === 'fulfilled');
if (ok) {
setConnection('live', 'Live database');
el.lastUpdated.textContent = 'Updated ' + new Date().toLocaleTimeString();
} else if (session) {
setConnection('error', 'No data from API');
el.lastUpdated.textContent = 'No data — check diagnostics';
toast('Every endpoint failed. Open “Session & Security” → Run diagnostics.', 'danger');
}
});
}
async function loadStats() {
try {
const payload = await api('/Admin/dashbord.php');
stats = Array.isArray(payload) ? payload[0] : payload;
renderStats();
} catch (err) {
handleApiError(err, 'stats');
markKpisUnavailable();
throw err;
}
}
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', { 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;
}
}
async function loadDrivers() {
tableMessage(el.driversTableBody, 8, 'Loading captains…');
try {
const payload = await api('/Admin/AdminCaptain/get.php', { params: { page: driversPage } });
driversPages = payload.pages || 1;
renderDrivers(payload.data || [], payload.total || 0);
} catch (err) {
if (!handleApiError(err, 'drivers')) tableMessage(el.driversTableBody, 8, err.message, true);
throw err;
}
}
async function loadPassengers() {
tableMessage(el.passengersTableBody, 8, 'Loading passengers…');
try {
const rows = await api('/Admin/getPassengerDetails.php');
renderPassengers(Array.isArray(rows) ? rows : []);
} catch (err) {
if (!handleApiError(err, 'passengers')) tableMessage(el.passengersTableBody, 8, err.message, true);
throw err;
}
}
async function loadApprovals() {
tableMessage(el.approvalsTableBody, 7, 'Loading requests…');
try {
const payload = await api('/Admin/Staff/pending.php');
renderApprovals(payload?.data || []);
} catch (err) {
if (!handleApiError(err, 'approvals')) tableMessage(el.approvalsTableBody, 7, err.message, true);
throw err;
}
}
async function loadRidesTrend() {
try {
const rows = await api('/Admin/AdminRide/getRidesPerMonth.php');
const series = (Array.isArray(rows) ? rows : []).slice(-21).map((r) => ({
label: `${String(r.day).padStart(2, '0')}/${String(r.month).padStart(2, '0')}`,
value: Number(r.rides_count) || 0,
}));
drawLineChart('ridesTrendChart', series);
} catch (err) {
handleApiError(err, 'rides-trend');
throw err;
}
}
// ── Renderers ────────────────────────────────────────────────────────────
function renderStats() {
if (!stats) return;
const total = num(stats.countRide);
const completed = num(stats.completed_rides);
setKpi('rides', fmtInt(total));
setKpi('ridesMonth', fmtInt(stats.countRideThisMonth));
setKpi('completed', fmtInt(completed));
setKpi('completionRate', total ? `${((completed / total) * 100).toFixed(1)}%` : '—');
setKpi('drivers', fmtInt(stats.countDriver));
setKpi('driversMonth', fmtInt(stats.countDriverThisMonth));
setKpi('passengers', fmtInt(stats.countPassengers));
setKpi('passengersMonth', fmtInt(stats.countPassengersThisMonth));
// 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));
setKpi('complaintsWeek', fmtInt(stats.countComplaintThisWeek));
setKpi('complaintsMonth', fmtInt(stats.countComplaintThisMonth));
drawDonut('statusDonut', [
{ label: 'Completed', value: num(stats.completed_rides), color: '#10b981' },
{ label: 'Waiting', value: num(stats.ongoing_rides), color: '#6366f1' },
{ label: 'Cancelled', value: num(stats.cancelled_rides), color: '#f43f5e' },
], el.statusLegend);
drawBarChart('timeOfDayChart', [
{ label: 'Morning 6–11', value: num(stats.morning_ride_count) },
{ label: 'Afternoon 12–17', value: num(stats.evening_ride_count) },
{ label: 'Night 18–5', value: num(stats.night_ride_count) },
]);
renderServiceMix();
renderTripPerformance();
}
function renderServiceMix() {
const types = [
{ label: 'Comfort', value: num(stats.comfort), color: '#6366f1' },
{ label: 'Speed', value: num(stats.speed), color: '#06b6d4' },
{ label: 'Lady', value: num(stats.lady), color: '#8b5cf6' },
];
const total = types.reduce((s, t) => s + t.value, 0) || 1;
el.serviceMix.innerHTML = types.map((t) => `
<div class="mix-row">
<div class="mix-head">
<span>${t.label}</span>
<strong>${fmtInt(t.value)} <span class="mix-pct">${((t.value / total) * 100).toFixed(1)}%</span></strong>
</div>
<div class="mix-track"><div class="mix-fill" style="width:${(t.value / total) * 100}%; background:${t.color};"></div></div>
</div>
`).join('');
}
function renderTripPerformance() {
const rows = [
['Average trip duration', stats.driver_avg_duration || '—'],
['Longest trip duration', stats.longest_duration || '—'],
['Average distance', `${fmtNum(stats.average_distance)} km`],
['Longest distance', `${fmtNum(stats.longest_distance)} km`],
['Cancelled rides', fmtInt(stats.cancelled_rides)],
['Captains with completed trips', fmtInt(stats.num_Driver)],
];
el.tripPerformance.innerHTML = rows.map(([k, v]) => `
<div class="kv-row"><span>${k}</span><strong>${v}</strong></div>
`).join('');
}
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;
}
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">${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(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('');
el.ridesTableBody.querySelectorAll('[data-ride]').forEach((btn) => {
btn.addEventListener('click', () => {
const ride = allRides.find((r) => String(r.id) === btn.dataset.ride);
if (ride) showRideDetails(ride);
});
});
}
// 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.
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) {
tableMessage(el.driversTableBody, 8, 'No captains found.');
return;
}
el.driversTableBody.innerHTML = drivers.map((d) => `
<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(maskPhone(d.phone))}</td>
<td>${esc(d.email || '—')}</td>
<td>${rating(d.passengerAverageRating)}</td>
<td>${fmtInt(d.countPassengerRide)}</td>
<td>${fmtInt(d.countPassengerCancel)}</td>
<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) {
if (!rows.length) {
tableMessage(el.passengersTableBody, 8, 'No passengers found.');
return;
}
el.passengersTableBody.innerHTML = rows.map((p) => `
<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 || maskPhone(p.phone))}</td>
<td>${fmtInt(p.countPassengerRide)}</td>
<td>${rating(p.passengerAverageRating)}</td>
<td>${fmtInt(p.countPassengerCancel)}</td>
<td>${esc(fmtDate(p.created_at, true))}</td>
<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) {
el.approvalsCount.hidden = pending.length === 0;
el.approvalsCount.textContent = pending.length;
if (!pending.length) {
tableMessage(el.approvalsTableBody, 7, 'No pending requests.');
return;
}
const isSuper = session?.role === 'super_admin';
el.approvalsTableBody.innerHTML = pending.map((p) => `
<tr>
<td><strong>#${esc(p.id)}</strong></td>
<td>${esc(p.name || '—')}</td>
<td>${esc(p.phone || '—')}</td>
<td><span class="badge badge-info">${esc(p.type)}</span></td>
<td>${esc(p.role || '—')}</td>
<td>${esc(fmtDate(p.created_at, true))}</td>
<td>${(isSuper && p.type === 'admin') ? `
<button class="btn btn-secondary btn-sm" data-approve="${esc(p.id)}"><i class="ph ph-check"></i> Approve</button>
<button class="btn btn-secondary btn-sm danger-btn" data-reject="${esc(p.id)}"><i class="ph ph-x"></i></button>
` : '<span class="stamp">super admin only</span>'}</td>
</tr>
`).join('');
el.approvalsTableBody.querySelectorAll('[data-approve]').forEach((b) =>
b.addEventListener('click', () => decideApproval(b.dataset.approve, 'approved')));
el.approvalsTableBody.querySelectorAll('[data-reject]').forEach((b) =>
b.addEventListener('click', () => decideApproval(b.dataset.reject, 'rejected')));
}
async function decideApproval(adminId, action) {
if (!confirm(`Set admin #${adminId} to "${action}"?`)) return;
try {
const form = new FormData();
form.append('admin_id', adminId);
form.append('action', action);
await api('/Admin/auth/approve_admin.php', { method: 'POST', body: form });
toast(`Admin #${adminId} ${action}.`, 'success');
loadApprovals();
} catch (err) {
if (!handleApiError(err, 'approve')) toast(err.message, 'danger');
}
}
function renderSessionInfo() {
if (!session) return;
const expiresAt = new Date(session.issuedAt + session.expiresIn * 1000);
const rows = [
['Signed in as', session.name],
['Admin ID', session.id ?? '—'],
['Role', formatRole(session.role)],
['Token expires', expiresAt.toLocaleString()],
['Device fingerprint', deviceFingerprint.slice(0, 24) + '…'],
['API endpoint', location.origin + API_BASE],
];
el.sessionInfo.innerHTML = rows.map(([k, v]) =>
`<div class="kv-row"><span>${k}</span><strong>${esc(String(v))}</strong></div>`).join('');
}
// ── Extended modules (parity with the Flutter admin app) ────────────────
// Each entry becomes a sidebar item plus a lazily-loaded page. Panels are
// rendered by shape, not by hand-written field lists, so an endpoint that
// grows a column shows it without a code change here.
const MODULES = [
{
id: 'liveOps', group: 'Realtime & Analytics', icon: 'ph-broadcast', title: 'Live Operations',
subtitle: 'Realtime fleet counters and the alerts that need attention now',
panels: [
{ title: 'Realtime counters', path: '/Admin/v2/realtime_dashboard.php' },
{ title: 'Smart alerts', path: '/Admin/v2/smart_alerts.php' },
],
},
{
id: 'growth', group: 'Realtime & Analytics', icon: 'ph-trend-up', title: 'Growth',
subtitle: 'Daily signups for passengers and captains',
panels: [{ title: 'Growth', path: '/Admin/v2/analytics/growth.php' }],
},
{
id: 'analyticsV2', group: 'Realtime & Analytics', icon: 'ph-chart-line', title: 'Advanced Analytics',
subtitle: 'Revenue, ranking and dashboard aggregates from the v2 engine',
panels: [
{ title: 'Revenue', path: '/Admin/v2/analytics/revenue.php' },
{ title: 'Driver ranking', path: '/Admin/v2/analytics/driver_ranking.php' },
{ title: 'Dashboard data', path: '/Admin/v2/analytics/dashboard_data.php' },
],
},
{
id: 'financeV2', group: 'Finance', icon: 'ph-bank', title: 'Financial V2',
subtitle: 'Settlement runs and financial aggregates',
panels: [
{ title: 'Financial stats', path: '/Admin/v2/financial/stats.php' },
{ title: 'Settlements', path: '/Admin/v2/financial/settlements.php' },
],
},
{
id: 'marketing', group: 'Growth & Pricing', icon: 'ph-megaphone', title: 'Marketing Intelligence',
subtitle: 'Market share, competitor price gaps, anomalies and campaign history',
panels: [
{ title: 'Market share', path: '/Admin/marketing/get_market_share_analytics.php' },
{ title: 'Price comparison', path: '/Admin/marketing/get_price_comparison.php' },
{ title: 'Market anomalies', path: '/Admin/marketing/get_market_anomalies.php' },
{ title: 'Surge opportunity index', path: '/Admin/marketing/surge_opportunity_index.php' },
{ title: 'Win-back hotspots', path: '/Admin/marketing/winback_hotspot_targets.php' },
{ title: 'Campaign log', path: '/Admin/marketing/get_campaigns_log.php' },
],
},
{
id: 'pricing', group: 'Growth & Pricing', icon: 'ph-sliders', title: 'Pricing Engine',
subtitle: 'Stability log, AI predictions and the live price-gap heatmap',
panels: [
{ title: 'Pricing stability log', path: '/Admin/marketing/get_pricing_stability_log.php' },
{ title: 'AI price prediction', path: '/Admin/marketing/ai_price_prediction.php' },
{ title: 'Price gap heatmap', path: '/Admin/marketing/get_price_gap_heatmap.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',
subtitle: 'Geofenced demand density',
panels: [{ title: 'Heatmap', path: '/Admin/geofence/get_heatmap.php' }],
},
{
id: 'quality', group: 'Quality', icon: 'ph-prohibit', title: 'Blacklist',
subtitle: 'Blocked captains and passengers',
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',
subtitle: 'Behaviour and reliability scoring per captain',
panels: [{ title: 'Scorecard', path: '/Admin/v2/quality/driver_scorecard.php' }],
},
{
id: 'transit', group: 'Transit', icon: 'ph-bus', title: 'Mawasalati Organisations',
subtitle: 'Registered transit organisations and their pending routes',
panels: [
{ title: 'Organisations', path: '/Admin/transit/org/list.php' },
{ title: 'Routes awaiting approval', path: '/Admin/transit/route/pending.php' },
],
},
{
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' }],
},
{
id: 'audit', group: 'Administration', icon: 'ph-scroll', title: 'Audit Log',
subtitle: 'Privileged actions recorded across the platform',
panels: [{ title: 'Audit entries', path: '/Admin/v2/security/audit_logs.php' }],
},
{
id: 'errors', group: 'Administration', icon: 'ph-bug', title: 'Error Log',
subtitle: 'Last errors reported by the mobile apps',
panels: [{ title: 'Recent errors', path: '/Admin/error/error_list_last20.php' }],
},
];
const loadedModules = new Set();
function buildModules() {
const menu = document.querySelector('.sidebar-menu');
const main = document.querySelector('.content-body');
if (!menu || !main) return;
const groups = [...new Set(MODULES.map((m) => m.group))];
groups.forEach((group) => {
const label = document.createElement('div');
label.className = 'menu-label';
label.textContent = group;
menu.appendChild(label);
MODULES.filter((m) => m.group === group).forEach((mod) => {
const item = document.createElement('a');
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);
const section = document.createElement('section');
section.className = 'page-view';
section.id = `mod_${mod.id}`;
section.innerHTML = `
<div class="page-header">
<div class="page-title">
<h1>${esc(mod.title)}</h1>
<p>${esc(mod.subtitle)}</p>
</div>
<div class="page-actions">
<button class="btn btn-secondary btn-sm" data-reload="${mod.id}">
<i class="ph ph-arrows-clockwise"></i> Reload
</button>
</div>
</div>
<div class="module-panels" id="panels_${mod.id}"></div>`;
main.appendChild(section);
section.querySelector('[data-reload]').addEventListener('click', () => loadModule(mod, true));
});
});
}
async function loadModule(mod, force = false) {
if (loadedModules.has(mod.id) && !force) return;
loadedModules.add(mod.id);
const host = $(`panels_${mod.id}`);
host.innerHTML = mod.panels.map((p) => `
<div class="card" data-panel="${esc(p.path)}">
<div class="card-header"><h3 class="card-title">${esc(p.title)}</h3></div>
<div class="panel-body"><div class="table-msg">Loading…</div></div>
</div>`).join('');
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, { params: p.params || null });
renderPayload(body, payload);
} catch (err) {
if (handleApiError(err, mod.id)) return;
body.innerHTML = `<div class="table-msg is-error">${esc(err.message)}</div>`;
}
}));
}
function cssEscape(value) {
return String(value).replace(/["\\]/g, '\\$&');
}
// Renders whatever the endpoint returned: scalars become KPI tiles, arrays
// of objects become tables, and nested objects recurse under their key.
function renderPayload(host, payload, depth = 0) {
host.innerHTML = '';
host.appendChild(buildNode(payload, depth));
}
function buildNode(payload, depth) {
const frag = document.createDocumentFragment();
if (payload === null || payload === undefined || payload === '') {
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 recorded for this yet — the table is empty in the database.'));
return frag;
}
if (typeof payload !== 'object') {
frag.appendChild(msgNode(String(payload)));
return frag;
}
const scalars = [];
const nested = [];
Object.entries(payload).forEach(([key, value]) => {
if (value !== null && typeof value === 'object') nested.push([key, value]);
else scalars.push([key, value]);
});
if (scalars.length) {
const grid = document.createElement('div');
grid.className = 'kpi-tiles';
grid.innerHTML = scalars.map(([k, v]) => `
<div class="kpi-tile">
<div class="kpi-tile-value">${esc(formatValue(v, k))}</div>
<div class="kpi-tile-label">${esc(humanize(k))}</div>
</div>`).join('');
frag.appendChild(grid);
}
nested.forEach(([key, value]) => {
const wrap = document.createElement('div');
wrap.className = 'sub-panel';
const heading = document.createElement('h4');
heading.className = 'sub-panel-title';
heading.textContent = humanize(key);
wrap.appendChild(heading);
wrap.appendChild(buildNode(value, depth + 1));
frag.appendChild(wrap);
});
return frag;
}
function buildTable(rows) {
const objects = rows.every((r) => r && typeof r === 'object' && !Array.isArray(r));
if (!objects) {
const list = document.createElement('div');
list.className = 'mini-list';
list.innerHTML = rows.map((r) => `<div class="kv-row"><span>${esc(String(r))}</span></div>`).join('');
return list;
}
const columns = [...new Set(rows.flatMap((r) => Object.keys(r)))]
.filter((c) => !/token|password|fingerprint/i.test(c))
.slice(0, 12);
const wrap = document.createElement('div');
wrap.className = 'table-responsive';
wrap.innerHTML = `
<table class="data-table">
<thead><tr>${columns.map((c) => `<th>${esc(humanize(c))}</th>`).join('')}</tr></thead>
<tbody>
${rows.slice(0, 100).map((row) => `<tr>${columns.map((c) => `<td>${cellHtml(c, row[c])}</td>`).join('')}</tr>`).join('')}
</tbody>
</table>`;
return wrap;
}
function cellHtml(column, value) {
if (value === null || value === undefined || value === '') return '<span class="stamp">—</span>';
if (typeof value === 'object') return `<span class="stamp">${esc(JSON.stringify(value).slice(0, 60))}</span>`;
if (/status|type|state|result/i.test(column)) {
return `<span class="badge ${badgeClass(value)}">${esc(labelStatus(value))}</span>`;
}
return esc(formatValue(value, column));
}
function formatValue(value, column = '') {
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
if (value === null || value === undefined || value === '') return '—';
const str = String(value);
if (/^\d{4}-\d{2}-\d{2}[ T]/.test(str)) return fmtDate(str);
if (/(price|fare|amount|revenue|earning|balance|payout|commission|total_paid)/i.test(column) && !isNaN(Number(str))) {
return fmtMoney(str);
}
// Identifiers, phones and codes are digit strings that must never be
// grouped with thousand separators — "0790000000" is not 790,000,000.
const isIdentifier = /(^|_)(id|phone|code|number|otp|year|zip|lat|lng|latitude|longitude)($|_)/i.test(column);
if (!isIdentifier && !str.startsWith('0') && /^-?\d+(\.\d+)?$/.test(str) && str.length < 12) {
return fmtNum(str);
}
return str;
}
function humanize(key) {
return String(key)
.replace(/[_-]+/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/\b\w/g, (m) => m.toUpperCase())
.trim();
}
function msgNode(text) {
const node = document.createElement('div');
node.className = 'table-msg';
node.textContent = text;
return node;
}
// ── Diagnostics ──────────────────────────────────────────────────────────
// Prints exactly what the server replies for every endpoint the console
// uses, so a blank dashboard can be traced to a status code instead of a
// 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 }],
['Passengers', '/Admin/getPassengerDetails.php'],
['Pending approvals', '/Admin/Staff/pending.php'],
['Rides per month', '/Admin/AdminRide/getRidesPerMonth.php'],
];
async function runDiagnostics() {
const out = el.diagnosticsOutput;
if (!out) return;
busy(el.runDiagnosticsBtn, true, 'Running…');
const lines = [
`Siro Admin diagnostics — ${new Date().toISOString()}`,
`Page origin : ${location.origin}`,
`API base : ${API_BASE}`,
`Fingerprint : ${deviceFingerprint.slice(0, 20)}…`,
`Token : ${session?.jwt ? 'present (role ' + session.role + ')' : 'MISSING — not signed in'}`,
'─'.repeat(64),
];
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}` } : {}),
},
});
const text = await res.text();
const ms = Math.round(performance.now() - started);
lines.push(
`${res.ok ? '✔' : '✘'} ${name}`,
` ${url}`,
` HTTP ${res.status} ${res.statusText} · ${ms}ms · ${text.length} bytes`,
` ${collapse(text).slice(0, 400)}`,
''
);
} catch (err) {
lines.push(
`✘ ${name}`,
` ${url}`,
` NETWORK FAILURE — ${err.message}`,
' (blocked by CORS, DNS, mixed content, or the host is unreachable)',
''
);
}
}
out.textContent = lines.join('\n');
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()) {
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)';
}
function setupDiagnostics() {
if (!el.apiBaseSelect) return;
el.apiBaseSelect.innerHTML = API_CANDIDATES
.map((c) => `<option value="${esc(c.value)}">${esc(c.label)}</option>`)
.join('') + '<option value="__custom__">Custom…</option>';
const known = API_CANDIDATES.some((c) => c.value === API_BASE);
el.apiBaseSelect.value = known ? API_BASE : '__custom__';
el.apiBaseCustom.value = known ? '' : API_BASE;
el.apiBaseCustom.hidden = known;
el.apiBaseSelect.addEventListener('change', () => {
el.apiBaseCustom.hidden = el.apiBaseSelect.value !== '__custom__';
});
el.saveApiBaseBtn.addEventListener('click', () => {
const chosen = el.apiBaseSelect.value === '__custom__'
? el.apiBaseCustom.value.trim().replace(/\/$/, '')
: el.apiBaseSelect.value;
if (!chosen) return;
API_BASE = chosen;
localStorage.setItem(API_BASE_KEY, chosen);
toast(`API base set to ${chosen}. Reloading data…`, 'success');
renderSessionInfo();
if (session) loadEverything();
});
el.runDiagnosticsBtn.addEventListener('click', runDiagnostics);
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);
toast('Diagnostics report copied.', 'success');
} catch {
toast('Copy failed — select the text manually.', 'warning');
}
});
}
function showRideDetails(r) {
const body = $('modalBodyContent');
const rows = [
['Status', labelStatus(r.status)],
['Passenger', r.passenger_full_name],
['Passenger phone', maskPhone(r.p_phone)],
['Completed trips (passenger)', fmtInt(r.p_completed)],
['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 || r.start_location],
['Drop-off', r.address_end || r.end_location],
['Distance', r.distance ? `${fmtNum(r.distance)} km` : '—'],
['Passenger fare', fmtMoney(rideFare(r))],
['Captain earning', r.price_for_driver ? fmtMoney(r.price_for_driver) : '—'],
['Payment method', r.paymentMethod],
['Service', r.carType],
['Requested', rideTimestamp(r)],
['Started', r.rideTimeStart ? fmtDate(r.rideTimeStart) : '—'],
['Finished', r.rideTimeFinish ? fmtDate(r.rideTimeFinish) : '—'],
['Cancellation note', r.cancel_reason],
];
body.innerHTML = `
<div class="modal-head">
<h3>Trip #${esc(r.id)}</h3>
<button class="btn-icon" onclick="closeModal()"><i class="ph ph-x"></i></button>
</div>
<div class="mini-list">
${rows.map(([k, v]) => `<div class="kv-row"><span>${k}</span><strong>${esc(String(v ?? '—') || '—')}</strong></div>`).join('')}
</div>`;
$('detailsModal').classList.add('active');
}
window.closeModal = () => $('detailsModal')?.classList.remove('active');
// ── Charts (dependency-free canvas rendering) ────────────────────────────
const chartData = new Map();
function prepareCanvas(id) {
const canvas = $(id);
if (!canvas || !canvas.parentElement) return null;
const ratio = window.devicePixelRatio || 1;
// Collapse the canvas first: a sized canvas props its own container open,
// so measuring before resetting would make charts grow but never shrink.
canvas.width = 0;
canvas.height = 0;
const w = canvas.parentElement.clientWidth;
const h = canvas.parentElement.clientHeight;
canvas.width = w * ratio;
canvas.height = h * ratio;
const ctx = canvas.getContext('2d');
ctx.scale(ratio, ratio);
ctx.clearRect(0, 0, w, h);
return { ctx, w, h };
}
function drawLineChart(id, series) {
chartData.set(id, { type: 'line', series });
const c = prepareCanvas(id);
if (!c || !series.length) return;
const { ctx, w, h } = c;
const padX = 34, padTop = 16, padBottom = 26;
const max = Math.max(...series.map((s) => s.value), 1);
const stepX = series.length > 1 ? (w - padX * 2) / (series.length - 1) : 0;
const y = (v) => h - padBottom - (v / max) * (h - padTop - padBottom);
const pts = series.map((s, i) => ({ x: padX + i * stepX, y: y(s.value) }));
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
ctx.fillStyle = '#64748b';
ctx.font = '10px Inter, sans-serif';
for (let i = 0; i <= 4; i++) {
const gy = padTop + i * (h - padTop - padBottom) / 4;
ctx.beginPath(); ctx.moveTo(padX, gy); ctx.lineTo(w - padX + 10, gy); ctx.stroke();
ctx.textAlign = 'right';
ctx.fillText(String(Math.round(max - i * max / 4)), padX - 6, gy + 3);
}
const grad = ctx.createLinearGradient(0, padTop, 0, h - padBottom);
grad.addColorStop(0, 'rgba(99,102,241,0.35)');
grad.addColorStop(1, 'rgba(99,102,241,0)');
ctx.beginPath();
pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)));
ctx.lineTo(pts[pts.length - 1].x, h - padBottom);
ctx.lineTo(pts[0].x, h - padBottom);
ctx.closePath();
ctx.fillStyle = grad;
ctx.fill();
ctx.beginPath();
pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)));
ctx.strokeStyle = '#6366f1';
ctx.lineWidth = 2.5;
ctx.lineJoin = 'round';
ctx.stroke();
const last = pts[pts.length - 1];
ctx.beginPath();
ctx.arc(last.x, last.y, 4.5, 0, Math.PI * 2);
ctx.fillStyle = '#6366f1';
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.stroke();
ctx.fillStyle = '#64748b';
ctx.textAlign = 'center';
const every = Math.ceil(series.length / 7);
series.forEach((s, i) => {
if (i % every === 0 || i === series.length - 1) ctx.fillText(s.label, pts[i].x, h - 8);
});
}
function drawBarChart(id, series) {
chartData.set(id, { type: 'bar', series });
const c = prepareCanvas(id);
if (!c || !series.length) return;
const { ctx, w, h } = c;
const padBottom = 28, padTop = 14;
const max = Math.max(...series.map((s) => s.value), 1);
const slot = w / series.length;
const barW = Math.min(64, slot * 0.5);
series.forEach((s, i) => {
const barH = (s.value / max) * (h - padTop - padBottom);
const x = i * slot + (slot - barW) / 2;
const yTop = h - padBottom - barH;
const grad = ctx.createLinearGradient(0, yTop, 0, h - padBottom);
grad.addColorStop(0, '#6366f1');
grad.addColorStop(1, 'rgba(99,102,241,0.25)');
ctx.fillStyle = grad;
roundRect(ctx, x, yTop, barW, barH, 6);
ctx.fill();
ctx.fillStyle = '#f8fafc';
ctx.font = '600 11px Inter, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(fmtInt(s.value), x + barW / 2, yTop - 5);
ctx.fillStyle = '#64748b';
ctx.font = '10px Inter, sans-serif';
ctx.fillText(s.label, x + barW / 2, h - 9);
});
}
function drawDonut(id, slices, legendEl) {
chartData.set(id, { type: 'donut', series: slices, legendEl });
const c = prepareCanvas(id);
if (!c) return;
const { ctx, w, h } = c;
const total = slices.reduce((s, x) => s + x.value, 0);
const cx = w / 2, cy = h / 2;
const r = Math.min(w, h) / 2 - 8;
const inner = r * 0.62;
if (!total) {
ctx.fillStyle = '#64748b';
ctx.font = '12px Inter, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('No ride data', cx, cy);
} else {
let angle = -Math.PI / 2;
slices.forEach((s) => {
const sweep = (s.value / total) * Math.PI * 2;
ctx.beginPath();
ctx.arc(cx, cy, r, angle, angle + sweep);
ctx.arc(cx, cy, inner, angle + sweep, angle, true);
ctx.closePath();
ctx.fillStyle = s.color;
ctx.fill();
angle += sweep;
});
ctx.fillStyle = '#f8fafc';
ctx.font = '600 18px Outfit, Inter, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(fmtInt(total), cx, cy + 2);
ctx.fillStyle = '#64748b';
ctx.font = '10px Inter, sans-serif';
ctx.fillText('TOTAL RIDES', cx, cy + 18);
}
if (legendEl) {
legendEl.innerHTML = slices.map((s) => `
<span class="legend-item"><i style="background:${s.color}"></i>${s.label}
<strong>${total ? ((s.value / total) * 100).toFixed(1) : '0.0'}%</strong></span>
`).join('');
}
}
function roundRect(ctx, x, y, w, h, r) {
const radius = Math.min(r, h / 2, w / 2);
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.arcTo(x + w, y, x + w, y + h, radius);
ctx.arcTo(x + w, y + h, x, y + h, radius);
ctx.arcTo(x, y + h, x, y, radius);
ctx.arcTo(x, y, x + w, y, radius);
ctx.closePath();
}
function redrawCharts() {
chartData.forEach((cfg, id) => {
if (cfg.type === 'line') drawLineChart(id, cfg.series);
else if (cfg.type === 'bar') drawBarChart(id, cfg.series);
else drawDonut(id, cfg.series, cfg.legendEl);
});
}
// ── Navigation & misc events ─────────────────────────────────────────────
function setupNavigation() {
const navItems = document.querySelectorAll('.nav-item[data-view]');
navItems.forEach((item) => {
item.addEventListener('click', (e) => {
e.preventDefault();
navItems.forEach((n) => n.classList.remove('active'));
item.classList.add('active');
document.querySelectorAll('.page-view').forEach((v) =>
v.classList.toggle('active', v.id === item.dataset.view));
if (window.innerWidth <= 992) el.sidebar.classList.remove('open');
redrawCharts();
const mod = MODULES.find((m) => m.id === item.dataset.module);
if (mod && session) loadModule(mod);
});
});
el.toggleSidebar?.addEventListener('click', () => el.sidebar.classList.toggle('open'));
}
function setupDataEvents() {
el.refreshBtn?.addEventListener('click', () => { if (session) loadEverything(); });
el.rideStatusFilter?.addEventListener('change', () => loadRides().catch(() => {}));
el.driversPrev?.addEventListener('click', () => {
if (driversPage > 1) { driversPage--; loadDrivers().catch(() => {}); }
});
el.driversNext?.addEventListener('click', () => {
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');
active?.querySelectorAll('tbody tr').forEach((tr) => {
tr.style.display = tr.textContent.toLowerCase().includes(q) ? '' : 'none';
});
});
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(redrawCharts, 150);
});
}
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; });
}
function markKpisUnavailable() {
document.querySelectorAll('[data-kpi]').forEach((n) => { n.textContent = 'n/a'; });
}
function tableMessage(tbody, cols, message, isError = false) {
if (!tbody) return;
tbody.innerHTML = `<tr><td colspan="${cols}" class="table-msg ${isError ? 'is-error' : ''}">${esc(message)}</td></tr>`;
}
function setConnection(state, text) {
if (!el.connectionPill) return;
el.connectionPill.dataset.state = state;
el.connectionText.textContent = text;
}
function busy(btn, isBusy, label) {
if (!btn) return;
btn.disabled = isBusy;
const span = btn.querySelector('span');
if (span) span.textContent = label;
}
const num = (v) => Number(v) || 0;
const fmtInt = (v) => num(v).toLocaleString('en-US');
const fmtNum = (v) => num(v).toLocaleString('en-US', { maximumFractionDigits: 2 });
const fmtMoney = (v) => num(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' JOD';
function fmtDate(value, dateOnly = false) {
if (!value) return '—';
const d = new Date(String(value).replace(' ', 'T'));
if (isNaN(d)) return String(value);
return dateOnly ? d.toLocaleDateString() : d.toLocaleString();
}
function rating(v) {
const r = Number(v);
if (!r) return '<span class="stamp">unrated</span>';
return `<i class="ph-fill ph-star" style="color:var(--warning)"></i> ${r.toFixed(2)}`;
}
function shorten(text, max = 26) {
if (!text) return '—';
const s = String(text);
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) {
if (!status) return 'Unknown';
return STATUS_LABELS[String(status).toLowerCase()] || humanize(status);
}
function badgeClass(status) {
const s = String(status || '').toLowerCase();
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';
}
function formatRole(role) {
return String(role || '').replace(/_/g, ' ').replace(/\b\w/g, (m) => m.toUpperCase()) || 'Admin';
}
function initials(name) {
return String(name || 'A').trim().split(/\s+/).slice(0, 2).map((w) => w[0]).join('').toUpperCase();
}
function esc(value) {
return String(value ?? '').replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
function toast(message, type = 'info') {
const icons = { success: 'ph-check-circle', danger: 'ph-warning-octagon', warning: 'ph-warning', info: 'ph-info' };
const node = document.createElement('div');
node.className = `toast toast-${type}`;
node.innerHTML = `<i class="ph-fill ${icons[type] || icons.info}"></i><span>${esc(message)}</span>`;
document.body.appendChild(node);
setTimeout(() => {
node.classList.add('leaving');
setTimeout(() => node.remove(), 300);
}, 4200);
}
})();