Files
Siro/dashboard/siro-admin/js/app.js
T

918 lines
34 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 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) =>
({ '&': '&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);
}
})();