/* ==========================================================================
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));
setKpi('driverEarnings', fmtMoney(stats.total_driver_earnings));
setKpi('avgFare', 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) => `
${t.label}
${fmtInt(t.value)} ${((t.value / total) * 100).toFixed(1)}%
`).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]) => `
${k}${v}
`).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) => {
const fare = r.price_for_passenger ?? r.price ?? 0;
return `
| #${esc(r.id)} |
${esc(r.passenger_full_name || 'Unknown')} |
${esc(r.driver_full_name || 'Unassigned')} |
${esc(shorten(r.address_start))} ${esc(shorten(r.address_end))} |
${fmtMoney(fare)} |
${esc(labelStatus(r.status))} |
${esc(fmtDate(r.created_at || r.date))} |
|
`;
}).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);
});
});
}
// ── 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 = `
${esc(title)}
Loading profile…
`;
$('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 = `
${esc(title)}
${entries.map(([k, v]) => `
${esc(k)}${esc(String(v))}
`).join('')}
`;
} catch (err) {
if (handleApiError(err, 'profile')) return;
body.innerHTML = `
${esc(title)}
${esc(err.message)}
`;
}
}
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) => `
| #${esc(d.id)} |
${esc(`${d.first_name || ''} ${d.last_name || ''}`.trim() || 'Unnamed')} |
${esc(maskPhone(d.phone))} |
${esc(d.email || '—')} |
${rating(d.passengerAverageRating)} |
${fmtInt(d.countPassengerRide)} |
${fmtInt(d.countPassengerCancel)} |
${esc(d.status || 'unknown')} |
`).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) => `
| #${esc(p.id)} |
${esc(`${p.first_name || ''} ${p.last_name || ''}`.trim() || 'Unnamed')} |
${esc(p.email || maskPhone(p.phone))} |
${fmtInt(p.countPassengerRide)} |
${rating(p.passengerAverageRating)} |
${fmtInt(p.countPassengerCancel)} |
${esc(fmtDate(p.created_at, true))} |
${esc(p.status || 'unknown')} |
`).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) => `
| #${esc(p.id)} |
${esc(p.name || '—')} |
${esc(p.phone || '—')} |
${esc(p.type)} |
${esc(p.role || '—')} |
${esc(fmtDate(p.created_at, true))} |
${(isSuper && p.type === 'admin') ? `
` : 'super admin only'} |
`).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]) =>
`${k}${esc(String(v))}
`).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: '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 = `${esc(mod.title)}`;
menu.appendChild(item);
const section = document.createElement('section');
section.className = 'page-view';
section.id = `mod_${mod.id}`;
section.innerHTML = `
`;
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) => `
`).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 = `${esc(err.message)}
`;
}
}));
}
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]) => `
${esc(formatValue(v, k))}
${esc(humanize(k))}
`).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) => `${esc(String(r))}
`).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 = `
${columns.map((c) => `| ${esc(humanize(c))} | `).join('')}
${rows.slice(0, 100).map((row) => `${columns.map((c) => `| ${cellHtml(c, row[c])} | `).join('')}
`).join('')}
`;
return wrap;
}
function cellHtml(column, value) {
if (value === null || value === undefined || value === '') return '—';
if (typeof value === 'object') return `${esc(JSON.stringify(value).slice(0, 60))}`;
if (/status|type|state|result/i.test(column)) {
return `${esc(labelStatus(value))}`;
}
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');
}
// 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) => ``)
.join('') + '';
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)));
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],
['Drop-off', r.address_end],
['Distance', r.distance ? `${fmtNum(r.distance)} km` : '—'],
['Passenger fare', fmtMoney(r.price_for_passenger)],
['Captain earning', fmtMoney(r.price_for_driver)],
['Service', r.carType],
['Started', fmtDate(r.rideTimeStart)],
['Finished', fmtDate(r.rideTimeFinish)],
['Cancellation note', r.cancel_reason],
];
body.innerHTML = `
Trip #${esc(r.id)}
${rows.map(([k, v]) => `
${k}${esc(String(v ?? '—') || '—')}
`).join('')}
`;
$('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) => `
${s.label}
${total ? ((s.value / total) * 100).toFixed(1) : '0.0'}%
`).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 = `| ${esc(message)} |
`;
}
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 'unrated';
return ` ${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;
}
function labelStatus(status) {
const map = {
Finished: 'Completed', Begin: 'In progress', Apply: 'Captain assigned',
Applied: 'Captain assigned', Arrived: 'Captain arrived', arrived: 'Captain arrived',
New: 'Waiting', nothing: 'Waiting', waiting: 'Waiting', wait: 'Waiting',
Cancel: 'Cancelled', CancelFromDriver: 'Cancelled by captain',
CancelFromDriverAfterApply: 'Cancelled by captain', CancelFromPassenger: 'Cancelled by passenger',
TimeOut: 'Timed out',
};
return map[status] || status || 'Unknown';
}
function badgeClass(status) {
const s = String(status || '').toLowerCase();
if (['finished', 'active', 'approved', 'online'].includes(s)) return 'badge-success';
if (s.startsWith('cancel') || ['timeout', 'suspended', 'rejected', 'blocked'].includes(s)) return 'badge-danger';
if (['begin', 'apply', 'applied', 'arrived'].includes(s)) return 'badge-primary';
if (['pending', 'new', 'wait', 'waiting', 'nothing'].includes(s)) return 'badge-warning';
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) =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 = `${esc(message)}`;
document.body.appendChild(node);
setTimeout(() => {
node.classList.add('leaving');
setTimeout(() => node.remove(), 300);
}, 4200);
}
})();