/* ========================================================================== 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) => `
${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(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 ` #${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 = 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) => ` #${esc(d.id)} ${esc(`${d.first_name || ''} ${d.last_name || ''}`.trim() || 'Unnamed')} ${esc(d.phone || '—')} ${esc(d.email || '—')} ${rating(d.passengerAverageRating)} ${fmtInt(d.countPassengerRide)} ${fmtInt(d.countPassengerCancel)} ${esc(d.status || 'unknown')} `).join(''); } 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 || p.phone || '—')} ${fmtInt(p.countPassengerRide)} ${rating(p.passengerAverageRating)} ${fmtInt(p.countPassengerCancel)} ${esc(fmtDate(p.created_at, true))} ${esc(p.status || 'unknown')} `).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) => ` #${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(''); } 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 = `
${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(); }); }); 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 = `${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); } })();