/* ========================================================================== Siro Admin Console — application engine All figures rendered here come from the production API. There is no mock/demo dataset: when a request fails the table says so instead of showing invented numbers. ========================================================================== */ (() => { 'use strict'; const SESSION_KEY = 'siro_admin_user'; const FP_KEY = 'siro_web_fp'; const API_BASE_KEY = 'siro_api_base'; // The Flutter admin app talks to a per-country host (see // siro_admin/lib/constant/links.dart). The web console defaults to the // backend deployed next to it, but the operator can repoint it. const API_CANDIDATES = [ { label: 'Same origin (/backend)', value: '/backend' }, { label: 'Jordan — jordan-siro.intaleqapp.com', value: 'https://jordan-siro.intaleqapp.com/backend' }, { label: 'Default — api.siromove.com', value: 'https://api.siromove.com/siro_v3' }, { label: 'Syria — api-syria.siromove.com', value: 'https://api-syria.siromove.com/siro_v3' }, { label: 'Egypt — api-egypt.siromove.com', value: 'https://api-egypt.siromove.com/siro_v3' }, ]; let API_BASE = localStorage.getItem(API_BASE_KEY) || '/backend'; // ── Session state ──────────────────────────────────────────────────────── let session = null; // { id, name, role, jwt, issuedAt, expiresIn } let deviceFingerprint = ''; let stats = null; // latest row from dashbord.php let driversPage = 1; let driversPages = 1; let allRides = []; let ridesShown = 0; const RIDES_PAGE_SIZE = 25; const $ = (id) => document.getElementById(id); const el = {}; document.addEventListener('DOMContentLoaded', init); async function init() { cacheElements(); deviceFingerprint = await resolveFingerprint(); if (el.fpPreview) el.fpPreview.textContent = deviceFingerprint.slice(0, 12) + '…'; buildModules(); setupNavigation(); setupAuthEvents(); setupDataEvents(); setupDiagnostics(); session = readSession(); if (session?.jwt) { showConsole(); loadEverything(); } else { showLogin(); } } function cacheElements() { [ 'authWrapper', 'loginForm', 'loginEmail', 'loginPass', 'loginSubmitBtn', 'fpPreview', 'otpModal', 'otpInput', 'otpPhoneText', 'submitOtpBtn', 'sidebar', 'toggleSidebar', 'logoutBtn', 'refreshBtn', 'globalSearch', 'connectionPill', 'connectionText', 'lastUpdated', 'userName', 'userRole', 'userAvatar', 'approvalsCount', 'ridesTableBody', 'ridesMeta', 'rideStatusFilter', 'driversTableBody', 'driversMeta', 'driversPrev', 'driversNext', 'passengersTableBody', 'approvalsTableBody', 'ridesSearch', 'ridesSearchBtn', 'ridesMore', 'driversSearch', 'driversSearchBtn', 'passengersSearch', 'passengersSearchBtn', 'decryptInput', 'decryptBtn', 'decryptOutput', 'statusLegend', 'serviceMix', 'tripPerformance', 'sessionInfo', 'apiBaseSelect', 'apiBaseCustom', 'saveApiBaseBtn', 'runDiagnosticsBtn', 'copyDiagnosticsBtn', 'diagnosticsOutput', ].forEach((id) => { el[id] = $(id); }); } // ── Device fingerprint ─────────────────────────────────────────────────── // The backend binds every access token to this value (JwtService compares // sha256(X-Device-FP + pepper) against the claim inside the JWT), so it must // stay identical for the whole life of the browser profile. async function resolveFingerprint() { const cached = localStorage.getItem(FP_KEY); if (cached) return cached; const traits = [ navigator.userAgent, navigator.platform || '', (navigator.languages || [navigator.language]).join(','), Intl.DateTimeFormat().resolvedOptions().timeZone || '', `${screen.width}x${screen.height}x${screen.colorDepth}`, String(navigator.hardwareConcurrency || 0), String(navigator.maxTouchPoints || 0), canvasSignature(), ].join('|'); let fp; try { const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(traits)); fp = 'web_' + [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join(''); } catch { // crypto.subtle is unavailable over plain HTTP — fall back to a random, // still-persistent identifier. fp = 'web_' + Math.random().toString(36).slice(2) + Date.now().toString(36); } localStorage.setItem(FP_KEY, fp); return fp; } function canvasSignature() { try { const c = document.createElement('canvas'); c.width = 200; c.height = 40; const ctx = c.getContext('2d'); ctx.textBaseline = 'top'; ctx.font = '14px Arial'; ctx.fillStyle = '#f60'; ctx.fillRect(0, 0, 100, 20); ctx.fillStyle = '#069'; ctx.fillText('siro-admin', 2, 4); return c.toDataURL().slice(-64); } catch { return 'no-canvas'; } } // ── API layer ──────────────────────────────────────────────────────────── // Every protected endpoint goes through connect.php → JwtService::authenticate, // which requires BOTH the bearer token and the X-Device-FP header. // NOTE: the backend's filterRequest() reads POST and JSON bodies only — it // never looks at $_GET. Any parameter sent as a query string is silently // dropped and the endpoint falls back to its default (which is why the ride // status filter always returned "Begin" and captain paging never advanced). // Every parameter therefore goes in a POST body. async function api(path, { method = null, body = null, params = null, auth = true } = {}) { const headers = { 'X-Device-FP': deviceFingerprint }; if (auth && session?.jwt) headers.Authorization = `Bearer ${session.jwt}`; if (params && !body) { body = new FormData(); Object.entries(params).forEach(([k, v]) => { if (v !== null && v !== undefined && v !== '') body.append(k, v); }); } const res = await fetch(API_BASE + path, { method: method || (body ? 'POST' : 'GET'), headers, body, }); const text = await res.text(); let json; try { json = JSON.parse(text); } catch { throw new ApiError(`Server returned a non-JSON response (HTTP ${res.status})`, res.status); } if (res.status === 401 || res.status === 403) { throw new ApiError(pickMessage(json) || 'Session rejected by the server', res.status); } if (json.status === 'failure' || json.error) { throw new ApiError(pickMessage(json) || 'Request failed', res.status); } // jsonSuccess()/printSuccess() put the payload in `message`; a few older // endpoints use `data`. return json.message !== undefined ? json.message : (json.data !== undefined ? json.data : json); } class ApiError extends Error { constructor(message, status) { super(message); this.status = status; } } function pickMessage(json) { if (!json) return null; if (typeof json.message === 'string') return json.message; if (typeof json.error === 'string') return json.error; if (json.message && typeof json.message.message === 'string') return json.message.message; return null; } function handleApiError(err, context) { console.error(`[${context}]`, err); if (err instanceof ApiError && (err.status === 401 || err.status === 403)) { signOut(`Session ended: ${err.message}`); return true; } setConnection('error', 'Data unavailable'); return false; } // ── Authentication ─────────────────────────────────────────────────────── let pendingPhone = ''; function setupAuthEvents() { el.loginForm?.addEventListener('submit', onLogin); el.submitOtpBtn?.addEventListener('click', onVerifyOtp); el.otpInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') onVerifyOtp(); }); el.logoutBtn?.addEventListener('click', () => signOut('You have been signed out.')); window.closeOtpModal = () => el.otpModal?.classList.remove('active'); } async function onLogin(e) { e.preventDefault(); const phone = el.loginEmail.value.trim(); const password = el.loginPass.value; if (!phone || !password) return; busy(el.loginSubmitBtn, true, 'Verifying…'); try { const form = new FormData(); form.append('phone', phone); form.append('password', password); form.append('fingerprint', deviceFingerprint); form.append('aud', 'admin'); const payload = await api('/Admin/auth/login.php', { method: 'POST', body: form, auth: false }); if (payload?.status === 'otp_required') { pendingPhone = phone; el.otpPhoneText.textContent = `Enter the 3-digit code sent to ${payload.phone || 'your WhatsApp'}.`; el.otpModal.classList.add('active'); el.otpInput.value = ''; el.otpInput.focus(); toast(payload.message || 'Verification code sent.', 'info'); } else if (payload?.jwt) { // Trusted-device renewal path — no OTP required. establishSession(payload); } else { toast('Unexpected response from the authentication service.', 'danger'); } } catch (err) { toast(err.message, 'danger'); } finally { busy(el.loginSubmitBtn, false, 'Sign In'); } } async function onVerifyOtp() { const otp = el.otpInput.value.trim(); if (otp.length < 3) { toast('Enter the 3-digit code.', 'warning'); return; } busy(el.submitOtpBtn, true, 'Verifying…'); try { const form = new FormData(); form.append('otp', otp); form.append('fingerprint', deviceFingerprint); form.append('aud', 'admin'); const payload = await api('/Admin/auth/verify_login.php', { method: 'POST', body: form, auth: false }); if (!payload?.jwt) throw new ApiError('No token issued.', 0); el.otpModal.classList.remove('active'); establishSession(payload); } catch (err) { toast(err.message, 'danger'); } finally { busy(el.submitOtpBtn, false, 'Verify & Sign In'); } } function establishSession(payload) { const admin = payload.admin || {}; session = { id: admin.id ?? null, name: admin.name || 'Admin', email: admin.email || pendingPhone, role: admin.role || 'admin', jwt: payload.jwt, issuedAt: Date.now(), expiresIn: Number(payload.expires_in) || 3600, }; localStorage.setItem(SESSION_KEY, JSON.stringify(session)); showConsole(); toast(`Welcome back, ${session.name}.`, 'success'); loadEverything(); } function readSession() { try { const raw = JSON.parse(localStorage.getItem(SESSION_KEY) || 'null'); if (!raw?.jwt) return null; // Access tokens live one hour; drop anything already expired locally so // we show the login form rather than a wall of failed requests. const age = (Date.now() - (raw.issuedAt || 0)) / 1000; if (age >= (raw.expiresIn || 3600)) return null; return raw; } catch { return null; } } function signOut(message) { localStorage.removeItem(SESSION_KEY); session = null; stats = null; loadedModules.clear(); showLogin(); if (message) toast(message, 'info'); } function showLogin() { el.authWrapper.classList.remove('hidden'); el.loginPass.value = ''; } function showConsole() { el.authWrapper.classList.add('hidden'); el.userName.textContent = session.name; el.userRole.textContent = formatRole(session.role); el.userAvatar.textContent = initials(session.name); applyRoleVisibility(); } // ── Data loading ───────────────────────────────────────────────────────── function loadEverything() { setConnection('loading', 'Loading live data…'); Promise.allSettled([ loadStats(), loadRides(), loadDrivers(), loadPassengers(), loadApprovals(), loadRidesTrend(), ]).then((results) => { renderSessionInfo(); const ok = results.some((r) => r.status === 'fulfilled'); if (ok) { setConnection('live', 'Live database'); el.lastUpdated.textContent = 'Updated ' + new Date().toLocaleTimeString(); } else if (session) { setConnection('error', 'No data from API'); el.lastUpdated.textContent = 'No data — check diagnostics'; toast('Every endpoint failed. Open “Session & Security” → Run diagnostics.', 'danger'); } }); } async function loadStats() { try { const payload = await api('/Admin/dashbord.php'); stats = Array.isArray(payload) ? payload[0] : payload; renderStats(); } catch (err) { handleApiError(err, 'stats'); markKpisUnavailable(); throw err; } } async function loadRides() { const status = el.rideStatusFilter?.value || 'All'; tableMessage(el.ridesTableBody, 8, 'Loading rides…'); if (el.ridesMore) el.ridesMore.hidden = true; try { const rides = await api('/Admin/rides/get_rides_by_status.php', { params: { status } }); allRides = Array.isArray(rides) ? rides : []; ridesShown = RIDES_PAGE_SIZE; renderRides(); } catch (err) { if (!handleApiError(err, 'rides')) tableMessage(el.ridesTableBody, 8, err.message, true); el.ridesMeta.textContent = '—'; allRides = []; if (el.ridesMore) el.ridesMore.hidden = true; throw err; } } async function loadDrivers() { tableMessage(el.driversTableBody, 8, 'Loading captains…'); try { const payload = await api('/Admin/AdminCaptain/get.php', { params: { page: driversPage } }); driversPages = payload.pages || 1; renderDrivers(payload.data || [], payload.total || 0); } catch (err) { if (!handleApiError(err, 'drivers')) tableMessage(el.driversTableBody, 8, err.message, true); throw err; } } async function loadPassengers() { tableMessage(el.passengersTableBody, 8, 'Loading passengers…'); try { const rows = await api('/Admin/getPassengerDetails.php'); renderPassengers(Array.isArray(rows) ? rows : []); } catch (err) { if (!handleApiError(err, 'passengers')) tableMessage(el.passengersTableBody, 8, err.message, true); throw err; } } async function loadApprovals() { tableMessage(el.approvalsTableBody, 7, 'Loading requests…'); try { const payload = await api('/Admin/Staff/pending.php'); renderApprovals(payload?.data || []); } catch (err) { if (!handleApiError(err, 'approvals')) tableMessage(el.approvalsTableBody, 7, err.message, true); throw err; } } async function loadRidesTrend() { try { const rows = await api('/Admin/AdminRide/getRidesPerMonth.php'); const series = (Array.isArray(rows) ? rows : []).slice(-21).map((r) => ({ label: `${String(r.day).padStart(2, '0')}/${String(r.month).padStart(2, '0')}`, value: Number(r.rides_count) || 0, })); drawLineChart('ridesTrendChart', series); } catch (err) { handleApiError(err, 'rides-trend'); throw err; } } // ── Renderers ──────────────────────────────────────────────────────────── function renderStats() { if (!stats) return; const total = num(stats.countRide); const completed = num(stats.completed_rides); setKpi('rides', fmtInt(total)); setKpi('ridesMonth', fmtInt(stats.countRideThisMonth)); setKpi('completed', fmtInt(completed)); setKpi('completionRate', total ? `${((completed / total) * 100).toFixed(1)}%` : '—'); setKpi('drivers', fmtInt(stats.countDriver)); setKpi('driversMonth', fmtInt(stats.countDriverThisMonth)); setKpi('passengers', fmtInt(stats.countPassengers)); setKpi('passengersMonth', fmtInt(stats.countPassengersThisMonth)); setKpi('driverEarnings', fmtMoney(stats.total_driver_earnings)); setKpi('avgFare', fmtMoney(stats.avg_passenger_price)); setKpi('totalDistance', `${fmtInt(stats.total_distance)} km`); setKpi('complaintsToday', fmtInt(stats.countComplaintToday)); setKpi('complaintsWeek', fmtInt(stats.countComplaintThisWeek)); setKpi('complaintsMonth', fmtInt(stats.countComplaintThisMonth)); drawDonut('statusDonut', [ { label: 'Completed', value: num(stats.completed_rides), color: '#10b981' }, { label: 'Waiting', value: num(stats.ongoing_rides), color: '#6366f1' }, { label: 'Cancelled', value: num(stats.cancelled_rides), color: '#f43f5e' }, ], el.statusLegend); drawBarChart('timeOfDayChart', [ { label: 'Morning 6–11', value: num(stats.morning_ride_count) }, { label: 'Afternoon 12–17', value: num(stats.evening_ride_count) }, { label: 'Night 18–5', value: num(stats.night_ride_count) }, ]); renderServiceMix(); renderTripPerformance(); } function renderServiceMix() { const types = [ { label: 'Comfort', value: num(stats.comfort), color: '#6366f1' }, { label: 'Speed', value: num(stats.speed), color: '#06b6d4' }, { label: 'Lady', value: num(stats.lady), color: '#8b5cf6' }, ]; const total = types.reduce((s, t) => s + t.value, 0) || 1; el.serviceMix.innerHTML = types.map((t) => `
${t.label} ${fmtInt(t.value)} ${((t.value / total) * 100).toFixed(1)}%
`).join(''); } function renderTripPerformance() { const rows = [ ['Average trip duration', stats.driver_avg_duration || '—'], ['Longest trip duration', stats.longest_duration || '—'], ['Average distance', `${fmtNum(stats.average_distance)} km`], ['Longest distance', `${fmtNum(stats.longest_distance)} km`], ['Cancelled rides', fmtInt(stats.cancelled_rides)], ['Captains with completed trips', fmtInt(stats.num_Driver)], ]; el.tripPerformance.innerHTML = rows.map(([k, v]) => `
${k}${v}
`).join(''); } function renderRides() { const rides = allRides.slice(0, ridesShown); el.ridesMeta.textContent = allRides.length ? `Showing ${rides.length} of ${allRides.length} trip${allRides.length === 1 ? '' : 's'}` : '—'; el.ridesMore.hidden = ridesShown >= allRides.length; if (!rides.length) { tableMessage(el.ridesTableBody, 8, 'No rides match this filter.'); return; } el.ridesTableBody.innerHTML = rides.map((r) => { const fare = r.price_for_passenger ?? r.price ?? 0; return ` #${esc(r.id)} ${esc(r.passenger_full_name || 'Unknown')} ${esc(r.driver_full_name || 'Unassigned')} ${esc(shorten(r.address_start))} ${esc(shorten(r.address_end))} ${fmtMoney(fare)} ${esc(labelStatus(r.status))} ${esc(fmtDate(r.created_at || r.date))} `; }).join(''); el.ridesTableBody.querySelectorAll('[data-ride]').forEach((btn) => { btn.addEventListener('click', () => { const ride = allRides.find((r) => String(r.id) === btn.dataset.ride); if (ride) showRideDetails(ride); }); }); } // ── Role model ─────────────────────────────────────────────────────────── // Mirrors the Flutter admin app: a plain `admin` observes, a `super_admin` // edits, approves and sees unmasked contact details. const isSuperAdmin = () => session?.role === 'super_admin'; function maskPhone(phone) { if (!phone || phone === '—') return '—'; if (isSuperAdmin()) return String(phone); const s = String(phone); return s.length > 6 ? `${s.slice(0, 4)}****${s.slice(-2)}` : '****'; } function applyRoleVisibility() { document.querySelectorAll('[data-requires-super]').forEach((node) => { node.hidden = !isSuperAdmin(); }); } // ── Lookup by phone / id ───────────────────────────────────────────────── async function lookupRidesByPhone(phone) { tableMessage(el.ridesTableBody, 8, `Searching rides for ${phone}…`); try { const payload = await api('/Admin/rides/admin_get_rides_by_phone.php', { params: { phone } }); const rows = Array.isArray(payload) ? payload : (payload?.rides || payload?.data || []); allRides = rows; ridesShown = RIDES_PAGE_SIZE; renderRides(); if (!rows.length) tableMessage(el.ridesTableBody, 8, `No rides found for ${phone}.`); } catch (err) { if (!handleApiError(err, 'ride-lookup')) tableMessage(el.ridesTableBody, 8, err.message, true); } } async function lookupCaptain(term) { tableMessage(el.driversTableBody, 8, `Searching captains for ${term}…`); const params = /^\d+$/.test(term) && term.length < 8 ? { driver_id: term } : (term.includes('@') ? { driverEmail: term } : { driverPhone: term }); try { const payload = await api('/Admin/AdminCaptain/getCaptainDetailsByEmailOrIDOrPhone.php', { params }); const rows = normaliseRows(payload); renderDrivers(rows, rows.length); el.driversMeta.textContent = `Search results for “${term}”`; if (!rows.length) tableMessage(el.driversTableBody, 8, `No captain matches “${term}”.`); } catch (err) { if (!handleApiError(err, 'captain-lookup')) tableMessage(el.driversTableBody, 8, err.message, true); } } async function lookupPassenger(term) { tableMessage(el.passengersTableBody, 8, `Searching passengers for ${term}…`); const params = /^\d+$/.test(term) && term.length < 8 ? { passengerId: term } : (term.includes('@') ? { passengerEmail: term } : { passengerphone: term }); try { const payload = await api('/Admin/getPassengerbyEmail.php', { params }); const rows = normaliseRows(payload); renderPassengers(rows); if (!rows.length) tableMessage(el.passengersTableBody, 8, `No passenger matches “${term}”.`); } catch (err) { if (!handleApiError(err, 'passenger-lookup')) tableMessage(el.passengersTableBody, 8, err.message, true); } } function normaliseRows(payload) { if (Array.isArray(payload)) return payload; if (payload && typeof payload === 'object') { if (Array.isArray(payload.data)) return payload.data; return [payload]; } return []; } // ── Profile drawers ────────────────────────────────────────────────────── async function openCaptainProfile(id) { openProfile(`Captain #${id}`, () => api('/Admin/AdminCaptain/getCaptainDetailsById.php', { params: { driver_id: id } })); } async function openPassengerProfile(id) { openProfile(`Passenger #${id}`, () => api('/Admin/getPassengerDetailsByPassengerID.php', { params: { passengerID: id } })); } async function openProfile(title, fetcher) { const body = $('modalBodyContent'); body.innerHTML = `
Loading profile…
`; $('detailsModal').classList.add('active'); try { const payload = await fetcher(); const record = normaliseRows(payload)[0]; if (!record) throw new ApiError('No profile returned for this record.', 0); const entries = Object.entries(record) .filter(([k]) => !/token|password|fingerprint/i.test(k)) .map(([k, v]) => [humanize(k), /phone/i.test(k) ? maskPhone(v) : formatValue(v, k)]); body.innerHTML = `
${entries.map(([k, v]) => `
${esc(k)}${esc(String(v))}
`).join('')}
`; } catch (err) { if (handleApiError(err, 'profile')) return; body.innerHTML = `
${esc(err.message)}
`; } } function renderDrivers(drivers, total) { el.driversMeta.textContent = `Page ${driversPage} of ${driversPages} · ${fmtInt(total)} captains`; if (!drivers.length) { tableMessage(el.driversTableBody, 8, 'No captains found.'); return; } el.driversTableBody.innerHTML = drivers.map((d) => ` #${esc(d.id)} ${esc(`${d.first_name || ''} ${d.last_name || ''}`.trim() || 'Unnamed')} ${esc(maskPhone(d.phone))} ${esc(d.email || '—')} ${rating(d.passengerAverageRating)} ${fmtInt(d.countPassengerRide)} ${fmtInt(d.countPassengerCancel)} ${esc(d.status || 'unknown')} `).join(''); el.driversTableBody.querySelectorAll('[data-captain]').forEach((row) => row.addEventListener('click', () => openCaptainProfile(row.dataset.captain))); } function renderPassengers(rows) { if (!rows.length) { tableMessage(el.passengersTableBody, 8, 'No passengers found.'); return; } el.passengersTableBody.innerHTML = rows.map((p) => ` #${esc(p.id)} ${esc(`${p.first_name || ''} ${p.last_name || ''}`.trim() || 'Unnamed')} ${esc(p.email || maskPhone(p.phone))} ${fmtInt(p.countPassengerRide)} ${rating(p.passengerAverageRating)} ${fmtInt(p.countPassengerCancel)} ${esc(fmtDate(p.created_at, true))} ${esc(p.status || 'unknown')} `).join(''); el.passengersTableBody.querySelectorAll('[data-passenger]').forEach((row) => row.addEventListener('click', () => openPassengerProfile(row.dataset.passenger))); } function renderApprovals(pending) { el.approvalsCount.hidden = pending.length === 0; el.approvalsCount.textContent = pending.length; if (!pending.length) { tableMessage(el.approvalsTableBody, 7, 'No pending requests.'); return; } const isSuper = session?.role === 'super_admin'; el.approvalsTableBody.innerHTML = pending.map((p) => ` #${esc(p.id)} ${esc(p.name || '—')} ${esc(p.phone || '—')} ${esc(p.type)} ${esc(p.role || '—')} ${esc(fmtDate(p.created_at, true))} ${(isSuper && p.type === 'admin') ? ` ` : 'super admin only'} `).join(''); el.approvalsTableBody.querySelectorAll('[data-approve]').forEach((b) => b.addEventListener('click', () => decideApproval(b.dataset.approve, 'approved'))); el.approvalsTableBody.querySelectorAll('[data-reject]').forEach((b) => b.addEventListener('click', () => decideApproval(b.dataset.reject, 'rejected'))); } async function decideApproval(adminId, action) { if (!confirm(`Set admin #${adminId} to "${action}"?`)) return; try { const form = new FormData(); form.append('admin_id', adminId); form.append('action', action); await api('/Admin/auth/approve_admin.php', { method: 'POST', body: form }); toast(`Admin #${adminId} ${action}.`, 'success'); loadApprovals(); } catch (err) { if (!handleApiError(err, 'approve')) toast(err.message, 'danger'); } } function renderSessionInfo() { if (!session) return; const expiresAt = new Date(session.issuedAt + session.expiresIn * 1000); const rows = [ ['Signed in as', session.name], ['Admin ID', session.id ?? '—'], ['Role', formatRole(session.role)], ['Token expires', expiresAt.toLocaleString()], ['Device fingerprint', deviceFingerprint.slice(0, 24) + '…'], ['API endpoint', location.origin + API_BASE], ]; el.sessionInfo.innerHTML = rows.map(([k, v]) => `
${k}${esc(String(v))}
`).join(''); } // ── Extended modules (parity with the Flutter admin app) ──────────────── // Each entry becomes a sidebar item plus a lazily-loaded page. Panels are // rendered by shape, not by hand-written field lists, so an endpoint that // grows a column shows it without a code change here. const MODULES = [ { id: 'liveOps', group: 'Realtime & Analytics', icon: 'ph-broadcast', title: 'Live Operations', subtitle: 'Realtime fleet counters and the alerts that need attention now', panels: [ { title: 'Realtime counters', path: '/Admin/v2/realtime_dashboard.php' }, { title: 'Smart alerts', path: '/Admin/v2/smart_alerts.php' }, ], }, { id: 'growth', group: 'Realtime & Analytics', icon: 'ph-trend-up', title: 'Growth', subtitle: 'Daily signups for passengers and captains', panels: [{ title: 'Growth', path: '/Admin/v2/analytics/growth.php' }], }, { id: 'analyticsV2', group: 'Realtime & Analytics', icon: 'ph-chart-line', title: 'Advanced Analytics', subtitle: 'Revenue, ranking and dashboard aggregates from the v2 engine', panels: [ { title: 'Revenue', path: '/Admin/v2/analytics/revenue.php' }, { title: 'Driver ranking', path: '/Admin/v2/analytics/driver_ranking.php' }, { title: 'Dashboard data', path: '/Admin/v2/analytics/dashboard_data.php' }, ], }, { id: 'financeV2', group: 'Finance', icon: 'ph-bank', title: 'Financial V2', subtitle: 'Settlement runs and financial aggregates', panels: [ { title: 'Financial stats', path: '/Admin/v2/financial/stats.php' }, { title: 'Settlements', path: '/Admin/v2/financial/settlements.php' }, ], }, { id: 'marketing', group: 'Growth & Pricing', icon: 'ph-megaphone', title: 'Marketing Intelligence', subtitle: 'Market share, competitor price gaps, anomalies and campaign history', panels: [ { title: 'Market share', path: '/Admin/marketing/get_market_share_analytics.php' }, { title: 'Price comparison', path: '/Admin/marketing/get_price_comparison.php' }, { title: 'Market anomalies', path: '/Admin/marketing/get_market_anomalies.php' }, { title: 'Surge opportunity index', path: '/Admin/marketing/surge_opportunity_index.php' }, { title: 'Win-back hotspots', path: '/Admin/marketing/winback_hotspot_targets.php' }, { title: 'Campaign log', path: '/Admin/marketing/get_campaigns_log.php' }, ], }, { id: 'pricing', group: 'Growth & Pricing', icon: 'ph-sliders', title: 'Pricing Engine', subtitle: 'Stability log, AI predictions and the live price-gap heatmap', panels: [ { title: 'Pricing stability log', path: '/Admin/marketing/get_pricing_stability_log.php' }, { title: 'AI price prediction', path: '/Admin/marketing/ai_price_prediction.php' }, { title: 'Price gap heatmap', path: '/Admin/marketing/get_price_gap_heatmap.php' }, { title: 'Telemetry', path: '/Admin/marketing/get_telemetry.php' }, ], }, { id: 'geofence', group: 'Growth & Pricing', icon: 'ph-map-trifold', title: 'Demand Heatmap', subtitle: 'Geofenced demand density', panels: [{ title: 'Heatmap', path: '/Admin/geofence/get_heatmap.php' }], }, { id: 'quality', group: 'Quality', icon: 'ph-prohibit', title: 'Blacklist', subtitle: 'Blocked captains and passengers', panels: [{ title: 'Blacklist', path: '/Admin/v2/quality/blacklist_manager.php', params: { action_type: 'get_all' } }], }, { id: 'scorecard', group: 'Quality', icon: 'ph-medal', title: 'Driver Scorecard', subtitle: 'Behaviour and reliability scoring per captain', panels: [{ title: 'Scorecard', path: '/Admin/v2/quality/driver_scorecard.php' }], }, { id: 'transit', group: 'Transit', icon: 'ph-bus', title: 'Mawasalati Organisations', subtitle: 'Registered transit organisations and their pending routes', panels: [ { title: 'Organisations', path: '/Admin/transit/org/list.php' }, { title: 'Routes awaiting approval', path: '/Admin/transit/route/pending.php' }, ], }, { id: 'staff', superOnly: true, group: 'Administration', icon: 'ph-identification-badge', title: 'Staff & Employees', subtitle: 'Internal staff records', panels: [{ title: 'Employees', path: '/Admin/employee/get.php' }], }, { id: 'audit', group: 'Administration', icon: 'ph-scroll', title: 'Audit Log', subtitle: 'Privileged actions recorded across the platform', panels: [{ title: 'Audit entries', path: '/Admin/v2/security/audit_logs.php' }], }, { id: 'errors', group: 'Administration', icon: 'ph-bug', title: 'Error Log', subtitle: 'Last errors reported by the mobile apps', panels: [{ title: 'Recent errors', path: '/Admin/error/error_list_last20.php' }], }, ]; const loadedModules = new Set(); function buildModules() { const menu = document.querySelector('.sidebar-menu'); const main = document.querySelector('.content-body'); if (!menu || !main) return; const groups = [...new Set(MODULES.map((m) => m.group))]; groups.forEach((group) => { const label = document.createElement('div'); label.className = 'menu-label'; label.textContent = group; menu.appendChild(label); MODULES.filter((m) => m.group === group).forEach((mod) => { const item = document.createElement('a'); item.className = 'nav-item'; item.dataset.view = `mod_${mod.id}`; item.dataset.module = mod.id; if (mod.superOnly) { item.setAttribute('data-requires-super', ''); item.hidden = true; } item.innerHTML = `${esc(mod.title)}`; menu.appendChild(item); const section = document.createElement('section'); section.className = 'page-view'; section.id = `mod_${mod.id}`; section.innerHTML = `
`; main.appendChild(section); section.querySelector('[data-reload]').addEventListener('click', () => loadModule(mod, true)); }); }); } async function loadModule(mod, force = false) { if (loadedModules.has(mod.id) && !force) return; loadedModules.add(mod.id); const host = $(`panels_${mod.id}`); host.innerHTML = mod.panels.map((p) => `

${esc(p.title)}

Loading…
`).join(''); await Promise.all(mod.panels.map(async (p) => { const body = host.querySelector(`[data-panel="${cssEscape(p.path)}"] .panel-body`); try { const payload = await api(p.path, { params: p.params || null }); renderPayload(body, payload); } catch (err) { if (handleApiError(err, mod.id)) return; body.innerHTML = `
${esc(err.message)}
`; } })); } function cssEscape(value) { return String(value).replace(/["\\]/g, '\\$&'); } // Renders whatever the endpoint returned: scalars become KPI tiles, arrays // of objects become tables, and nested objects recurse under their key. function renderPayload(host, payload, depth = 0) { host.innerHTML = ''; host.appendChild(buildNode(payload, depth)); } function buildNode(payload, depth) { const frag = document.createDocumentFragment(); if (payload === null || payload === undefined || payload === '') { frag.appendChild(msgNode('The endpoint responded successfully but returned no data yet.')); return frag; } if (Array.isArray(payload)) { frag.appendChild(payload.length ? buildTable(payload) : msgNode('No records recorded for this yet — the table is empty in the database.')); return frag; } if (typeof payload !== 'object') { frag.appendChild(msgNode(String(payload))); return frag; } const scalars = []; const nested = []; Object.entries(payload).forEach(([key, value]) => { if (value !== null && typeof value === 'object') nested.push([key, value]); else scalars.push([key, value]); }); if (scalars.length) { const grid = document.createElement('div'); grid.className = 'kpi-tiles'; grid.innerHTML = scalars.map(([k, v]) => `
${esc(formatValue(v, k))}
${esc(humanize(k))}
`).join(''); frag.appendChild(grid); } nested.forEach(([key, value]) => { const wrap = document.createElement('div'); wrap.className = 'sub-panel'; const heading = document.createElement('h4'); heading.className = 'sub-panel-title'; heading.textContent = humanize(key); wrap.appendChild(heading); wrap.appendChild(buildNode(value, depth + 1)); frag.appendChild(wrap); }); return frag; } function buildTable(rows) { const objects = rows.every((r) => r && typeof r === 'object' && !Array.isArray(r)); if (!objects) { const list = document.createElement('div'); list.className = 'mini-list'; list.innerHTML = rows.map((r) => `
${esc(String(r))}
`).join(''); return list; } const columns = [...new Set(rows.flatMap((r) => Object.keys(r)))] .filter((c) => !/token|password|fingerprint/i.test(c)) .slice(0, 12); const wrap = document.createElement('div'); wrap.className = 'table-responsive'; wrap.innerHTML = ` ${columns.map((c) => ``).join('')} ${rows.slice(0, 100).map((row) => `${columns.map((c) => ``).join('')}`).join('')}
${esc(humanize(c))}
${cellHtml(c, row[c])}
`; return wrap; } function cellHtml(column, value) { if (value === null || value === undefined || value === '') return '—'; if (typeof value === 'object') return `${esc(JSON.stringify(value).slice(0, 60))}`; if (/status|type|state|result/i.test(column)) { return `${esc(labelStatus(value))}`; } return esc(formatValue(value, column)); } function formatValue(value, column = '') { if (typeof value === 'boolean') return value ? 'Yes' : 'No'; if (value === null || value === undefined || value === '') return '—'; const str = String(value); if (/^\d{4}-\d{2}-\d{2}[ T]/.test(str)) return fmtDate(str); if (/(price|fare|amount|revenue|earning|balance|payout|commission|total_paid)/i.test(column) && !isNaN(Number(str))) { return fmtMoney(str); } // Identifiers, phones and codes are digit strings that must never be // grouped with thousand separators — "0790000000" is not 790,000,000. const isIdentifier = /(^|_)(id|phone|code|number|otp|year|zip|lat|lng|latitude|longitude)($|_)/i.test(column); if (!isIdentifier && !str.startsWith('0') && /^-?\d+(\.\d+)?$/.test(str) && str.length < 12) { return fmtNum(str); } return str; } function humanize(key) { return String(key) .replace(/[_-]+/g, ' ') .replace(/([a-z])([A-Z])/g, '$1 $2') .replace(/\b\w/g, (m) => m.toUpperCase()) .trim(); } function msgNode(text) { const node = document.createElement('div'); node.className = 'table-msg'; node.textContent = text; return node; } // ── Diagnostics ────────────────────────────────────────────────────────── // Prints exactly what the server replies for every endpoint the console // uses, so a blank dashboard can be traced to a status code instead of a // guess: 404 = wrong API base, 403 = role/device rejection, 401 = token. const PROBES = [ ['Dashboard stats', '/Admin/dashbord.php'], ['Rides', '/Admin/rides/get_rides_by_status.php', { status: 'All' }], ['Captains', '/Admin/AdminCaptain/get.php', { page: 1 }], ['Passengers', '/Admin/getPassengerDetails.php'], ['Pending approvals', '/Admin/Staff/pending.php'], ['Rides per month', '/Admin/AdminRide/getRidesPerMonth.php'], ]; async function runDiagnostics() { const out = el.diagnosticsOutput; if (!out) return; busy(el.runDiagnosticsBtn, true, 'Running…'); const lines = [ `Siro Admin diagnostics — ${new Date().toISOString()}`, `Page origin : ${location.origin}`, `API base : ${API_BASE}`, `Fingerprint : ${deviceFingerprint.slice(0, 20)}…`, `Token : ${session?.jwt ? 'present (role ' + session.role + ')' : 'MISSING — not signed in'}`, '─'.repeat(64), ]; for (const [name, path, params] of PROBES) { const url = API_BASE + path; const started = performance.now(); let form = null; if (params) { form = new FormData(); Object.entries(params).forEach(([k, v]) => form.append(k, v)); } try { const res = await fetch(url, { method: form ? 'POST' : 'GET', body: form, headers: { 'X-Device-FP': deviceFingerprint, ...(session?.jwt ? { Authorization: `Bearer ${session.jwt}` } : {}), }, }); const text = await res.text(); const ms = Math.round(performance.now() - started); lines.push( `${res.ok ? '✔' : '✘'} ${name}`, ` ${url}`, ` HTTP ${res.status} ${res.statusText} · ${ms}ms · ${text.length} bytes`, ` ${collapse(text).slice(0, 400)}`, '' ); } catch (err) { lines.push( `✘ ${name}`, ` ${url}`, ` NETWORK FAILURE — ${err.message}`, ' (blocked by CORS, DNS, mixed content, or the host is unreachable)', '' ); } } out.textContent = lines.join('\n'); busy(el.runDiagnosticsBtn, false, 'Run diagnostics'); } // Super-admin only: mirrors the Flutter EncryptToolPage (Admin/ggg.php). async function runCryptoTool(action) { if (!isSuperAdmin()) { toast('This tool is restricted to super admins.', 'warning'); return; } const text = el.decryptInput.value.trim(); const adminPhone = $('decryptPhone').value.trim(); if (!text || !adminPhone) { toast('Enter both your admin phone and the value.', 'warning'); return; } el.decryptOutput.textContent = 'Working…'; try { const payload = await api('/Admin/ggg.php', { params: { action, text, admin_phone: adminPhone }, }); el.decryptOutput.textContent = payload?.result ?? JSON.stringify(payload, null, 2); } catch (err) { el.decryptOutput.textContent = `Failed: ${err.message}`; } } function collapse(text) { return String(text).replace(/\s+/g, ' ').trim() || '(empty response body)'; } function setupDiagnostics() { if (!el.apiBaseSelect) return; el.apiBaseSelect.innerHTML = API_CANDIDATES .map((c) => ``) .join('') + ''; const known = API_CANDIDATES.some((c) => c.value === API_BASE); el.apiBaseSelect.value = known ? API_BASE : '__custom__'; el.apiBaseCustom.value = known ? '' : API_BASE; el.apiBaseCustom.hidden = known; el.apiBaseSelect.addEventListener('change', () => { el.apiBaseCustom.hidden = el.apiBaseSelect.value !== '__custom__'; }); el.saveApiBaseBtn.addEventListener('click', () => { const chosen = el.apiBaseSelect.value === '__custom__' ? el.apiBaseCustom.value.trim().replace(/\/$/, '') : el.apiBaseSelect.value; if (!chosen) return; API_BASE = chosen; localStorage.setItem(API_BASE_KEY, chosen); toast(`API base set to ${chosen}. Reloading data…`, 'success'); renderSessionInfo(); if (session) loadEverything(); }); el.runDiagnosticsBtn.addEventListener('click', runDiagnostics); document.querySelectorAll('[data-action="decrypt"], [data-action="encrypt"]').forEach((btn) => btn.addEventListener('click', () => runCryptoTool(btn.dataset.action))); el.copyDiagnosticsBtn.addEventListener('click', async () => { try { await navigator.clipboard.writeText(el.diagnosticsOutput.textContent); toast('Diagnostics report copied.', 'success'); } catch { toast('Copy failed — select the text manually.', 'warning'); } }); } function showRideDetails(r) { const body = $('modalBodyContent'); const rows = [ ['Status', labelStatus(r.status)], ['Passenger', r.passenger_full_name], ['Passenger phone', maskPhone(r.p_phone)], ['Completed trips (passenger)', fmtInt(r.p_completed)], ['Captain', r.driver_full_name], ['Captain phone', maskPhone(r.d_phone)], ['Captain completed / cancelled', `${fmtInt(r.d_completed)} / ${fmtInt(r.d_canceled)}`], ['Pickup', r.address_start], ['Drop-off', r.address_end], ['Distance', r.distance ? `${fmtNum(r.distance)} km` : '—'], ['Passenger fare', fmtMoney(r.price_for_passenger)], ['Captain earning', fmtMoney(r.price_for_driver)], ['Service', r.carType], ['Started', fmtDate(r.rideTimeStart)], ['Finished', fmtDate(r.rideTimeFinish)], ['Cancellation note', r.cancel_reason], ]; body.innerHTML = `
${rows.map(([k, v]) => `
${k}${esc(String(v ?? '—') || '—')}
`).join('')}
`; $('detailsModal').classList.add('active'); } window.closeModal = () => $('detailsModal')?.classList.remove('active'); // ── Charts (dependency-free canvas rendering) ──────────────────────────── const chartData = new Map(); function prepareCanvas(id) { const canvas = $(id); if (!canvas || !canvas.parentElement) return null; const ratio = window.devicePixelRatio || 1; // Collapse the canvas first: a sized canvas props its own container open, // so measuring before resetting would make charts grow but never shrink. canvas.width = 0; canvas.height = 0; const w = canvas.parentElement.clientWidth; const h = canvas.parentElement.clientHeight; canvas.width = w * ratio; canvas.height = h * ratio; const ctx = canvas.getContext('2d'); ctx.scale(ratio, ratio); ctx.clearRect(0, 0, w, h); return { ctx, w, h }; } function drawLineChart(id, series) { chartData.set(id, { type: 'line', series }); const c = prepareCanvas(id); if (!c || !series.length) return; const { ctx, w, h } = c; const padX = 34, padTop = 16, padBottom = 26; const max = Math.max(...series.map((s) => s.value), 1); const stepX = series.length > 1 ? (w - padX * 2) / (series.length - 1) : 0; const y = (v) => h - padBottom - (v / max) * (h - padTop - padBottom); const pts = series.map((s, i) => ({ x: padX + i * stepX, y: y(s.value) })); ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.fillStyle = '#64748b'; ctx.font = '10px Inter, sans-serif'; for (let i = 0; i <= 4; i++) { const gy = padTop + i * (h - padTop - padBottom) / 4; ctx.beginPath(); ctx.moveTo(padX, gy); ctx.lineTo(w - padX + 10, gy); ctx.stroke(); ctx.textAlign = 'right'; ctx.fillText(String(Math.round(max - i * max / 4)), padX - 6, gy + 3); } const grad = ctx.createLinearGradient(0, padTop, 0, h - padBottom); grad.addColorStop(0, 'rgba(99,102,241,0.35)'); grad.addColorStop(1, 'rgba(99,102,241,0)'); ctx.beginPath(); pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y))); ctx.lineTo(pts[pts.length - 1].x, h - padBottom); ctx.lineTo(pts[0].x, h - padBottom); ctx.closePath(); ctx.fillStyle = grad; ctx.fill(); ctx.beginPath(); pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y))); ctx.strokeStyle = '#6366f1'; ctx.lineWidth = 2.5; ctx.lineJoin = 'round'; ctx.stroke(); const last = pts[pts.length - 1]; ctx.beginPath(); ctx.arc(last.x, last.y, 4.5, 0, Math.PI * 2); ctx.fillStyle = '#6366f1'; ctx.fill(); ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.stroke(); ctx.fillStyle = '#64748b'; ctx.textAlign = 'center'; const every = Math.ceil(series.length / 7); series.forEach((s, i) => { if (i % every === 0 || i === series.length - 1) ctx.fillText(s.label, pts[i].x, h - 8); }); } function drawBarChart(id, series) { chartData.set(id, { type: 'bar', series }); const c = prepareCanvas(id); if (!c || !series.length) return; const { ctx, w, h } = c; const padBottom = 28, padTop = 14; const max = Math.max(...series.map((s) => s.value), 1); const slot = w / series.length; const barW = Math.min(64, slot * 0.5); series.forEach((s, i) => { const barH = (s.value / max) * (h - padTop - padBottom); const x = i * slot + (slot - barW) / 2; const yTop = h - padBottom - barH; const grad = ctx.createLinearGradient(0, yTop, 0, h - padBottom); grad.addColorStop(0, '#6366f1'); grad.addColorStop(1, 'rgba(99,102,241,0.25)'); ctx.fillStyle = grad; roundRect(ctx, x, yTop, barW, barH, 6); ctx.fill(); ctx.fillStyle = '#f8fafc'; ctx.font = '600 11px Inter, sans-serif'; ctx.textAlign = 'center'; ctx.fillText(fmtInt(s.value), x + barW / 2, yTop - 5); ctx.fillStyle = '#64748b'; ctx.font = '10px Inter, sans-serif'; ctx.fillText(s.label, x + barW / 2, h - 9); }); } function drawDonut(id, slices, legendEl) { chartData.set(id, { type: 'donut', series: slices, legendEl }); const c = prepareCanvas(id); if (!c) return; const { ctx, w, h } = c; const total = slices.reduce((s, x) => s + x.value, 0); const cx = w / 2, cy = h / 2; const r = Math.min(w, h) / 2 - 8; const inner = r * 0.62; if (!total) { ctx.fillStyle = '#64748b'; ctx.font = '12px Inter, sans-serif'; ctx.textAlign = 'center'; ctx.fillText('No ride data', cx, cy); } else { let angle = -Math.PI / 2; slices.forEach((s) => { const sweep = (s.value / total) * Math.PI * 2; ctx.beginPath(); ctx.arc(cx, cy, r, angle, angle + sweep); ctx.arc(cx, cy, inner, angle + sweep, angle, true); ctx.closePath(); ctx.fillStyle = s.color; ctx.fill(); angle += sweep; }); ctx.fillStyle = '#f8fafc'; ctx.font = '600 18px Outfit, Inter, sans-serif'; ctx.textAlign = 'center'; ctx.fillText(fmtInt(total), cx, cy + 2); ctx.fillStyle = '#64748b'; ctx.font = '10px Inter, sans-serif'; ctx.fillText('TOTAL RIDES', cx, cy + 18); } if (legendEl) { legendEl.innerHTML = slices.map((s) => ` ${s.label} ${total ? ((s.value / total) * 100).toFixed(1) : '0.0'}% `).join(''); } } function roundRect(ctx, x, y, w, h, r) { const radius = Math.min(r, h / 2, w / 2); ctx.beginPath(); ctx.moveTo(x + radius, y); ctx.arcTo(x + w, y, x + w, y + h, radius); ctx.arcTo(x + w, y + h, x, y + h, radius); ctx.arcTo(x, y + h, x, y, radius); ctx.arcTo(x, y, x + w, y, radius); ctx.closePath(); } function redrawCharts() { chartData.forEach((cfg, id) => { if (cfg.type === 'line') drawLineChart(id, cfg.series); else if (cfg.type === 'bar') drawBarChart(id, cfg.series); else drawDonut(id, cfg.series, cfg.legendEl); }); } // ── Navigation & misc events ───────────────────────────────────────────── function setupNavigation() { const navItems = document.querySelectorAll('.nav-item[data-view]'); navItems.forEach((item) => { item.addEventListener('click', (e) => { e.preventDefault(); navItems.forEach((n) => n.classList.remove('active')); item.classList.add('active'); document.querySelectorAll('.page-view').forEach((v) => v.classList.toggle('active', v.id === item.dataset.view)); if (window.innerWidth <= 992) el.sidebar.classList.remove('open'); redrawCharts(); const mod = MODULES.find((m) => m.id === item.dataset.module); if (mod && session) loadModule(mod); }); }); el.toggleSidebar?.addEventListener('click', () => el.sidebar.classList.toggle('open')); } function setupDataEvents() { el.refreshBtn?.addEventListener('click', () => { if (session) loadEverything(); }); el.rideStatusFilter?.addEventListener('change', () => loadRides().catch(() => {})); el.driversPrev?.addEventListener('click', () => { if (driversPage > 1) { driversPage--; loadDrivers().catch(() => {}); } }); el.driversNext?.addEventListener('click', () => { if (driversPage < driversPages) { driversPage++; loadDrivers().catch(() => {}); } }); el.ridesMore?.addEventListener('click', () => { ridesShown += RIDES_PAGE_SIZE; renderRides(); }); // Server-side lookups: these hit dedicated endpoints rather than filtering // the page, so an operator can find a record that is not in the last batch. bindSearch(el.ridesSearch, el.ridesSearchBtn, (term) => lookupRidesByPhone(term), () => loadRides()); bindSearch(el.driversSearch, el.driversSearchBtn, (term) => lookupCaptain(term), () => loadDrivers()); bindSearch(el.passengersSearch, el.passengersSearchBtn, (term) => lookupPassenger(term), () => loadPassengers()); el.globalSearch?.addEventListener('input', (e) => { const q = e.target.value.toLowerCase(); const active = document.querySelector('.page-view.active'); active?.querySelectorAll('tbody tr').forEach((tr) => { tr.style.display = tr.textContent.toLowerCase().includes(q) ? '' : 'none'; }); }); let resizeTimer; window.addEventListener('resize', () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(redrawCharts, 150); }); } function bindSearch(input, button, onSearch, onClear) { if (!input) return; const run = () => { const term = input.value.trim(); if (term) onSearch(term); else onClear(); }; button?.addEventListener('click', run); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') run(); }); input.addEventListener('input', () => { if (!input.value.trim()) onClear(); }); } // ── Small helpers ──────────────────────────────────────────────────────── function setKpi(key, value) { document.querySelectorAll(`[data-kpi="${key}"]`).forEach((n) => { n.textContent = value; }); } function markKpisUnavailable() { document.querySelectorAll('[data-kpi]').forEach((n) => { n.textContent = 'n/a'; }); } function tableMessage(tbody, cols, message, isError = false) { if (!tbody) return; tbody.innerHTML = `${esc(message)}`; } function setConnection(state, text) { if (!el.connectionPill) return; el.connectionPill.dataset.state = state; el.connectionText.textContent = text; } function busy(btn, isBusy, label) { if (!btn) return; btn.disabled = isBusy; const span = btn.querySelector('span'); if (span) span.textContent = label; } const num = (v) => Number(v) || 0; const fmtInt = (v) => num(v).toLocaleString('en-US'); const fmtNum = (v) => num(v).toLocaleString('en-US', { maximumFractionDigits: 2 }); const fmtMoney = (v) => num(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' JOD'; function fmtDate(value, dateOnly = false) { if (!value) return '—'; const d = new Date(String(value).replace(' ', 'T')); if (isNaN(d)) return String(value); return dateOnly ? d.toLocaleDateString() : d.toLocaleString(); } function rating(v) { const r = Number(v); if (!r) return 'unrated'; return ` ${r.toFixed(2)}`; } function shorten(text, max = 26) { if (!text) return '—'; const s = String(text); return s.length > max ? s.slice(0, max - 1) + '…' : s; } function labelStatus(status) { const map = { Finished: 'Completed', Begin: 'In progress', Apply: 'Captain assigned', Applied: 'Captain assigned', Arrived: 'Captain arrived', arrived: 'Captain arrived', New: 'Waiting', nothing: 'Waiting', waiting: 'Waiting', wait: 'Waiting', Cancel: 'Cancelled', CancelFromDriver: 'Cancelled by captain', CancelFromDriverAfterApply: 'Cancelled by captain', CancelFromPassenger: 'Cancelled by passenger', TimeOut: 'Timed out', }; return map[status] || status || 'Unknown'; } function badgeClass(status) { const s = String(status || '').toLowerCase(); if (['finished', 'active', 'approved', 'online'].includes(s)) return 'badge-success'; if (s.startsWith('cancel') || ['timeout', 'suspended', 'rejected', 'blocked'].includes(s)) return 'badge-danger'; if (['begin', 'apply', 'applied', 'arrived'].includes(s)) return 'badge-primary'; if (['pending', 'new', 'wait', 'waiting', 'nothing'].includes(s)) return 'badge-warning'; return 'badge-info'; } function formatRole(role) { return String(role || '').replace(/_/g, ' ').replace(/\b\w/g, (m) => m.toUpperCase()) || 'Admin'; } function initials(name) { return String(name || 'A').trim().split(/\s+/).slice(0, 2).map((w) => w[0]).join('').toUpperCase(); } function esc(value) { return String(value ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } function toast(message, type = 'info') { const icons = { success: 'ph-check-circle', danger: 'ph-warning-octagon', warning: 'ph-warning', info: 'ph-info' }; const node = document.createElement('div'); node.className = `toast toast-${type}`; node.innerHTML = `${esc(message)}`; document.body.appendChild(node); setTimeout(() => { node.classList.add('leaving'); setTimeout(() => node.remove(), 300); }, 4200); } })();