918 lines
34 KiB
JavaScript
918 lines
34 KiB
JavaScript
/* ==========================================================================
|
||
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 API_BASE = '/backend';
|
||
const SESSION_KEY = 'siro_admin_user';
|
||
const FP_KEY = 'siro_web_fp';
|
||
|
||
// ── 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;
|
||
|
||
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) + '…';
|
||
|
||
setupNavigation();
|
||
setupAuthEvents();
|
||
setupDataEvents();
|
||
|
||
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',
|
||
'statusLegend', 'serviceMix', 'tripPerformance', 'sessionInfo',
|
||
].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.
|
||
async function api(path, { method = 'GET', body = null, auth = true } = {}) {
|
||
const headers = { 'X-Device-FP': deviceFingerprint };
|
||
if (auth && session?.jwt) headers.Authorization = `Bearer ${session.jwt}`;
|
||
|
||
const res = await fetch(API_BASE + path, { method, headers, body });
|
||
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;
|
||
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);
|
||
}
|
||
|
||
// ── 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();
|
||
}
|
||
});
|
||
}
|
||
|
||
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…');
|
||
try {
|
||
const rides = await api(`/Admin/rides/get_rides_by_status.php?status=${encodeURIComponent(status)}`);
|
||
renderRides(Array.isArray(rides) ? rides : []);
|
||
} catch (err) {
|
||
if (!handleApiError(err, 'rides')) tableMessage(el.ridesTableBody, 8, err.message, true);
|
||
el.ridesMeta.textContent = '—';
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
async function loadDrivers() {
|
||
tableMessage(el.driversTableBody, 8, 'Loading captains…');
|
||
try {
|
||
const payload = await api(`/Admin/AdminCaptain/get.php?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) => `
|
||
<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(rides) {
|
||
el.ridesMeta.textContent = `${rides.length} trip${rides.length === 1 ? '' : 's'}`;
|
||
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 `
|
||
<tr>
|
||
<td><strong>#${esc(r.id)}</strong></td>
|
||
<td>${esc(r.passenger_full_name || 'Unknown')}</td>
|
||
<td>${esc(r.driver_full_name || 'Unassigned')}</td>
|
||
<td class="route-cell"><i class="ph ph-map-pin"></i> ${esc(shorten(r.address_start))} <i class="ph ph-arrow-right"></i> ${esc(shorten(r.address_end))}</td>
|
||
<td><strong>${fmtMoney(fare)}</strong></td>
|
||
<td><span class="badge ${badgeClass(r.status)}">${esc(labelStatus(r.status))}</span></td>
|
||
<td>${esc(fmtDate(r.created_at || r.date))}</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 = rides.find((r) => String(r.id) === btn.dataset.ride);
|
||
if (ride) showRideDetails(ride);
|
||
});
|
||
});
|
||
}
|
||
|
||
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>
|
||
<td><strong>#${esc(d.id)}</strong></td>
|
||
<td>${esc(`${d.first_name || ''} ${d.last_name || ''}`.trim() || 'Unnamed')}</td>
|
||
<td>${esc(d.phone || '—')}</td>
|
||
<td>${esc(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('');
|
||
}
|
||
|
||
function renderPassengers(rows) {
|
||
if (!rows.length) {
|
||
tableMessage(el.passengersTableBody, 8, 'No passengers found.');
|
||
return;
|
||
}
|
||
el.passengersTableBody.innerHTML = rows.map((p) => `
|
||
<tr>
|
||
<td><strong>#${esc(p.id)}</strong></td>
|
||
<td>${esc(`${p.first_name || ''} ${p.last_name || ''}`.trim() || 'Unnamed')}</td>
|
||
<td>${esc(p.email || p.phone || '—')}</td>
|
||
<td>${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('');
|
||
}
|
||
|
||
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('');
|
||
}
|
||
|
||
function showRideDetails(r) {
|
||
const body = $('modalBodyContent');
|
||
const rows = [
|
||
['Status', labelStatus(r.status)],
|
||
['Passenger', r.passenger_full_name],
|
||
['Passenger phone', r.p_phone],
|
||
['Completed trips (passenger)', fmtInt(r.p_completed)],
|
||
['Captain', r.driver_full_name],
|
||
['Captain phone', 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 = `
|
||
<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();
|
||
});
|
||
});
|
||
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.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);
|
||
});
|
||
}
|
||
|
||
// ── 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;
|
||
}
|
||
|
||
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 = `<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);
|
||
}
|
||
})();
|