/* ========================================================================== 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)); // A null aggregate means "nothing recorded yet" — showing 0.00 would read // as a measured zero. setKpi('driverEarnings', stats.total_driver_earnings == null ? '—' : fmtMoney(stats.total_driver_earnings)); setKpi('avgFare', stats.avg_passenger_price == null ? '—' : 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) => ` #${esc(r.id)} ${esc(r.passenger_full_name || 'Unknown')} ${esc(r.driver_full_name || 'Unassigned')} ${routeCell(r)} ${fmtMoney(rideFare(r))} ${esc(labelStatus(r.status))} ${esc(rideTimestamp(r))} `).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); }); }); } // The ride table carries two generations of columns. Older rows expose // address_start/address_end, price_for_passenger and created_at; rows written // by the current ride pipeline expose start_location/end_location as // "lat,lng" pairs, a plain `price`, and separate date + time columns. function rideFare(r) { return r.price_for_passenger ?? r.price ?? 0; } function rideTimestamp(r) { if (r.created_at) return fmtDate(r.created_at); if (r.date) return fmtDate(`${r.date} ${r.time && r.time !== '00:00:00' ? r.time : ''}`.trim()); return '—'; } function routeCell(r) { if (r.address_start || r.address_end) { return ` ${esc(shorten(r.address_start))} ${esc(shorten(r.address_end))}`; } if (r.start_location) { const link = mapLink(r.start_location); return ` ${esc(shortCoord(r.start_location))} ${esc(shortCoord(r.end_location))} `; } return '—'; } function shortCoord(value) { if (!value) return '—'; return String(value).split(',').map((n) => Number(n).toFixed(4)).join(', '); } function mapLink(coords) { return `https://www.openstreetmap.org/?mlat=${encodeURIComponent(String(coords).split(',')[0])}&mlon=${encodeURIComponent(String(coords).split(',')[1] || '')}#map=15/`; } // ── 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: 'tariff', group: 'Growth & Pricing', icon: 'ph-currency-circle-dollar', title: 'Tariff Editor', subtitle: 'The live Kazan tariff — every change here alters what passengers pay', custom: renderTariffEditor, }, { id: 'promos', group: 'Growth & Pricing', icon: 'ph-ticket', title: 'Promo Codes', subtitle: 'Active discount codes', panels: [{ title: 'Promo codes', path: '/ride/promo/get.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: 'fleet', group: 'Quality', icon: 'ph-steering-wheel', title: 'Fleet Performance', subtitle: 'Top captains, gift eligibility payouts and per-captain card charges', panels: [ { title: 'Best captains', path: '/Admin/driver/getBestDriver.php' }, { title: 'Card charges per captain', path: '/Admin/getVisaForEachDriver.php' }, ], }, { id: 'invoices', group: 'Finance', icon: 'ph-receipt', title: 'Invoices', subtitle: 'Invoice totals recorded against admin accounts', panels: [{ title: 'Invoice totals', path: '/Admin/adminUser/invoice_total.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: 'monitor', group: 'Realtime & Analytics', icon: 'ph-crosshair', title: 'Ride Monitor', subtitle: 'Look up the active ride and gift eligibility for a specific phone number', custom: (host) => renderLookupTools(host, [ { title: 'Active ride for a phone number', path: '/Admin/rides/monitorRide.php', field: 'phone', placeholder: 'Passenger or captain phone, e.g. 962798583052', }, { title: 'Gift payment eligibility', path: '/Admin/driver/getDriverGiftPayment.php', field: 'phone', placeholder: 'Captain phone', }, ]), }, { id: 'transit', group: 'Transit', icon: 'ph-bus', title: 'Mawasalati Organisations', subtitle: 'Registered transit organisations', panels: [{ title: 'Organisations', path: '/Admin/transit/org/list.php' }], }, { id: 'routes', group: 'Transit', icon: 'ph-path', title: 'Route Approvals', subtitle: 'Draft routes submitted by organisations, awaiting a decision', custom: renderRouteApprovals, }, { id: 'broadcast', superOnly: true, group: 'Administration', icon: 'ph-megaphone-simple', title: 'Broadcast Notification', subtitle: 'Push a notification to every captain or every passenger', custom: renderBroadcast, }, { id: 'driverDocs', group: 'Quality', icon: 'ph-identification-card', title: 'Driver Documents', subtitle: 'Captains awaiting document review and activation', custom: renderDriverDocs, }, { id: 'staff', superOnly: true, group: 'Administration', icon: 'ph-identification-badge', title: 'Staff & Employees', subtitle: 'Internal staff records, activation and onboarding', custom: renderStaff, }, { 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}`); if (mod.custom) { await mod.custom(host); return; } 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)}
`; } })); } // Endpoints that answer only for a specific record get an input rather than // an empty panel: they require a parameter, so eagerly calling them would // just render an error. function renderLookupTools(host, tools) { host.innerHTML = tools.map((tool, i) => `

${esc(tool.title)}

Enter a value above to query this endpoint.
`).join(''); host.querySelectorAll('[data-lookup-run]').forEach((btn) => { const index = Number(btn.dataset.lookupRun); const tool = tools[index]; const input = host.querySelector(`[data-lookup-input="${index}"]`); const body = host.querySelector(`[data-lookup-body="${index}"]`); const run = async () => { const value = input.value.trim(); if (!value) return; body.innerHTML = '
Querying…
'; try { const payload = await api(tool.path, { params: { [tool.field]: value } }); renderPayload(body, payload); } catch (err) { if (handleApiError(err, 'lookup')) return; body.innerHTML = `
${esc(err.message)}
`; } }; btn.addEventListener('click', run); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') run(); }); }); } // ── Driver document review ─────────────────────────────────────────────── // The list is paged server-side (limit/offset). Activation posts // status=active to Admin/driver/updateDriverFromAdmin.php, exactly as the // Flutter DriverDocsController does. const DOCS_PAGE_SIZE = 15; let docsOffset = 0; async function renderDriverDocs(host, offset = 0) { docsOffset = offset; host.innerHTML = '
Loading captains awaiting review…
'; let drivers = []; try { const payload = await api('/auth/driver/drivers_pending_list.php', { params: { limit: DOCS_PAGE_SIZE, offset }, }); drivers = normaliseRows(payload); } catch (err) { if (handleApiError(err, 'driver-docs')) return; host.innerHTML = `
${esc(err.message)}
`; return; } if (!drivers.length && offset === 0) { host.innerHTML = '
No captains are awaiting document review.
'; return; } host.innerHTML = `

Awaiting review showing ${drivers.length} from #${offset + 1}

${drivers.map((d) => ` `).join('')}
IDNamePhone
#${esc(d.id)} ${esc(`${d.first_name || ''} ${d.last_name || ''}`.trim() || 'Unnamed')} ${esc(maskPhone(d.phone))}
Pick a captain above to inspect their documents.
`; $('docsPrev')?.addEventListener('click', () => renderDriverDocs(host, Math.max(0, offset - DOCS_PAGE_SIZE))); $('docsNext')?.addEventListener('click', () => renderDriverDocs(host, offset + DOCS_PAGE_SIZE)); host.querySelectorAll('[data-review]').forEach((btn) => btn.addEventListener('click', () => showDriverDocs(btn.dataset.review, host))); } async function showDriverDocs(driverId, host) { const panel = $('docsDetail'); panel.innerHTML = '
Loading documents…
'; let driver = {}; let documents = []; try { // Sent as a POST body: filterRequest() ignores query strings, so the // mobile app's GET "?id=" form never reaches this endpoint's $driverId. const payload = await api('/auth/driver/driver_details.php', { params: { id: driverId } }); driver = payload?.driver || {}; documents = payload?.documents || []; } catch (err) { if (handleApiError(err, 'driver-details')) return; panel.innerHTML = `
${esc(err.message)}
`; return; } const facts = Object.entries(driver) .filter(([k, v]) => !/token|password|fingerprint/i.test(k) && v !== null && v !== '') .slice(0, 18); panel.innerHTML = `

${esc(`${driver.first_name || ''} ${driver.last_name || ''}`.trim() || `Captain #${driverId}`)} #${esc(driverId)} · ${esc(driver.status || 'unknown')}

Documents (${documents.length})

${documents.length ? `
${documents.map((doc) => `
${doc.link ? ` ${esc(doc.doc_type || 'document')} ` : '
no file linked
'}
${esc(humanize(doc.doc_type || 'document'))} ${esc(doc.image_name || '—')}
`).join('')}
` : '
This captain has uploaded no documents — approving now would activate an unverified account.
'}

Record

${facts.map(([k, v]) => `
${esc(humanize(k))} ${esc(/phone/i.test(k) ? maskPhone(v) : formatValue(v, k))}
`).join('')}
`; panel.querySelector('[data-approve-driver]').addEventListener('click', () => approveDriver(driverId, driver, documents.length, host)); } async function approveDriver(driverId, driver, docCount, host) { const name = `${driver.first_name || ''} ${driver.last_name || ''}`.trim() || `#${driverId}`; const warning = docCount === 0 ? '\n\nWARNING: no documents are on file for this captain.' : `\n\n${docCount} document(s) reviewed.`; if (!confirm(`Activate captain ${name}?${warning}\n\nThey will be able to accept rides immediately.`)) return; try { await api('/Admin/driver/updateDriverFromAdmin.php', { params: { id: driverId, status: 'active' }, }); toast(`Captain ${name} activated.`, 'success'); renderDriverDocs(host, docsOffset); } catch (err) { if (!handleApiError(err, 'driver-approve')) toast(err.message, 'danger'); } } // ── Staff management ───────────────────────────────────────────────────── async function renderStaff(host) { host.innerHTML = `
Loading pending accounts…
Loading employees…

Add a staff account

Creates a login for the Siro admin tools. Choose the password with the new member present, or have them change it at first sign-in — it is stored hashed and cannot be read back.

`; $('staffAdd').addEventListener('click', () => addStaff(host)); loadStaffPending(host); loadEmployees(); } async function loadStaffPending(host) { const panel = $('staffPending'); try { const payload = await api('/Admin/Staff/pending.php'); const rows = payload?.data || []; const sources = payload?.sources || {}; const notes = Object.entries(sources) .filter(([, state]) => state !== 'ok') .map(([name, state]) => `
${esc(humanize(name))}: ${esc(state)}
`) .join(''); panel.innerHTML = `

Pending activation

${notes} ${rows.length ? `
${rows.map((r) => ` `).join('')}
IDNamePhoneTypeRequested
#${esc(r.id)} ${esc(r.name || '—')} ${esc(maskPhone(r.phone))} ${esc(r.type)} ${esc(fmtDate(r.created_at, true))}
` : (notes ? '' : '
No accounts are waiting for activation.
')}`; panel.querySelectorAll('[data-activate]').forEach((btn) => btn.addEventListener('click', () => activateStaff(btn.dataset.activate, btn.dataset.type, host))); } catch (err) { if (handleApiError(err, 'staff-pending')) return; panel.innerHTML = `
${esc(err.message)}
`; } } async function loadEmployees() { const panel = $('staffList'); try { const payload = await api('/Admin/employee/get.php'); panel.innerHTML = '

Employees

'; renderPayload(panel.querySelector('.panel-body'), payload); } catch (err) { if (handleApiError(err, 'employees')) return; panel.innerHTML = `

Employees

${esc(err.message)}
`; } } async function activateStaff(userId, type, host) { if (!confirm(`Activate ${type} account #${userId}? They will be able to sign in immediately.`)) return; try { await api('/Admin/Staff/activate.php', { params: { user_id: userId, type } }); toast(`Account #${userId} activated.`, 'success'); loadStaffPending(host); } catch (err) { if (!handleApiError(err, 'staff-activate')) toast(err.message, 'danger'); } } async function addStaff(host) { const role = $('staffRole').value; const name = $('staffName').value.trim(); const phone = $('staffPhone').value.trim(); const email = $('staffEmail').value.trim(); const password = $('staffPassword').value; const country = $('staffCountry').value.trim() || 'Jordan'; const status = $('staffStatus'); if (!name || !password) { toast('Name and password are required.', 'warning'); return; } if (password.length < 8) { toast('Use a password of at least 8 characters.', 'warning'); return; } if (role === 'admin' && !isSuperAdmin()) { toast('Only a super admin can create administrator accounts.', 'warning'); return; } const roleLabel = role === 'admin' ? 'ADMINISTRATOR' : 'customer service'; if (!confirm( `Create a ${roleLabel} account for "${name}"?\n\n` + `Phone: ${phone || '—'}\nEmail: ${email || '—'}\n\n` + (role === 'admin' ? 'Administrators can see and change platform data.' : 'Customer service staff can view operational data.') )) return; busy($('staffAdd'), true, 'Creating…'); status.textContent = ''; try { await api('/Admin/Staff/add.php', { params: { name, phone, email, password, role, country }, }); status.textContent = `Created ${roleLabel} account for ${name}`; toast('Staff account created.', 'success'); ['staffName', 'staffPhone', 'staffEmail', 'staffPassword'].forEach((id) => { $(id).value = ''; }); loadStaffPending(host); } catch (err) { if (!handleApiError(err, 'staff-add')) toast(`Could not create account: ${err.message}`, 'danger'); } finally { busy($('staffAdd'), false, 'Create account'); } } // ── Route approvals ────────────────────────────────────────────────────── // transit/route/approve.php accepts approve | suspend | reject and refuses a // no-op transition, so each decision is confirmed against the route's stops. async function renderRouteApprovals(host) { host.innerHTML = '
Loading draft routes…
'; let routes = []; try { const payload = await api('/Admin/transit/route/pending.php'); routes = payload?.routes || []; } catch (err) { if (handleApiError(err, 'routes')) return; host.innerHTML = `
${esc(err.message)}
`; return; } if (!routes.length) { host.innerHTML = '
No routes are waiting for approval.
'; return; } host.innerHTML = routes.map((route, index) => `

${esc(route.name_ar || route.name_en || 'Unnamed route')} #${esc(route.id)} · ${esc(route.org_name || 'unknown organisation')}

${esc(route.direction || '—')}
Direction
${fmtNum(route.distance_km)} km
Distance
${fmtInt(route.duration_min)} min
Duration
${fmtInt(route.stops_count)}
Stops
${esc(route.country || '—')}
Country
${esc(fmtDate(route.created_at, true))}
Submitted

Stops

${(route.stops || []).length ? `
    ${route.stops.map((s) => `
  1. ${esc(s.name_ar || 'Unnamed stop')} ${Number(s.is_major) ? 'major' : ''} ${esc(shortCoord(`${s.latitude},${s.longitude}`))}
  2. `).join('')}
` : '
This route has no stops recorded.
'}
`).join(''); host.querySelectorAll('[data-route-action]').forEach((btn) => btn.addEventListener('click', () => decideRoute(routes[Number(btn.dataset.route)], btn.dataset.routeAction, host))); } async function decideRoute(route, action, host) { const verb = action === 'approve' ? 'approve' : 'reject'; const consequence = action === 'approve' ? 'The route goes live and passengers can ride it.' : 'The organisation will have to resubmit the route.'; if (!confirm( `${verb === 'approve' ? 'Approve' : 'Reject'} route "${route.name_ar || route.id}" ` + `from ${route.org_name || 'this organisation'}?\n\n` + `${fmtInt(route.stops_count)} stops · ${fmtNum(route.distance_km)} km\n\n${consequence}` )) return; try { await api('/Admin/transit/route/approve.php', { params: { route_id: route.id, action }, }); toast(`Route #${route.id} ${verb}ed.`, 'success'); renderRouteApprovals(host); } catch (err) { if (!handleApiError(err, 'route-decision')) toast(err.message, 'danger'); } } // ── Broadcast notifications ────────────────────────────────────────────── // Goes through Admin/notifications/broadcast.php, never the internal FCM // endpoint: the browser must not hold the internal API key. function renderBroadcast(host) { host.innerHTML = `
This reaches every device at once and cannot be recalled. The message is recorded in the audit log against your account.

Compose

Preview

Siro
Notification title
Message text
`; const title = $('bcTitle'); const body = $('bcBody'); const sync = () => { $('bcPreviewTitle').textContent = title.value.trim() || 'Notification title'; $('bcPreviewBody').textContent = body.value.trim() || 'Message text'; }; title.addEventListener('input', sync); body.addEventListener('input', sync); $('bcSend').addEventListener('click', () => sendBroadcast(host)); } async function sendBroadcast(host) { if (!isSuperAdmin()) { toast('Broadcasting is restricted to super admins.', 'warning'); return; } const audience = $('bcAudience').value; const title = $('bcTitle').value.trim(); const body = $('bcBody').value.trim(); const status = $('bcStatus'); const audienceLabel = audience === 'drivers' ? 'every captain' : 'every passenger'; if (!title || !body) { toast('Enter both a title and a message.', 'warning'); return; } if (!confirm( `Send this notification to ${audienceLabel} on the platform?\n\n` + `${title}\n${body}\n\n` + 'It is delivered immediately and cannot be recalled.' )) return; busy($('bcSend'), true, 'Sending…'); status.textContent = ''; try { const result = await api('/Admin/notifications/broadcast.php', { params: { audience, title, body }, }); status.textContent = `Sent to ${audienceLabel} at ${new Date().toLocaleTimeString()}`; toast(`Notification delivered to ${audienceLabel}.`, 'success'); $('bcTitle').value = ''; $('bcBody').value = ''; $('bcPreviewTitle').textContent = 'Notification title'; $('bcPreviewBody').textContent = 'Message text'; console.info('[broadcast]', result); } catch (err) { if (!handleApiError(err, 'broadcast')) toast(`Send failed: ${err.message}`, 'danger'); } finally { busy($('bcSend'), false, 'Review & send'); } } // ── Kazan tariff editor ────────────────────────────────────────────────── // Only these columns are accepted by ride/kazan/update.php; anything else // sent would be silently dropped, so the form mirrors that list exactly. const TARIFF_FIELDS = [ { key: 'kazanPercent', label: 'Platform commission', hint: '% taken by Siro' }, { key: 'fuelPrice', label: 'Fuel price' }, { key: 'currency', label: 'Currency', type: 'text' }, { key: 'normalMinPrice', label: 'Minimum fare — normal' }, { key: 'peakMinPrice', label: 'Minimum fare — peak' }, { key: 'lateMinPrice', label: 'Minimum fare — late night' }, { key: 'fixedPrice', label: 'Fixed price' }, { key: 'speedPrice', label: 'Speed' }, { key: 'comfortPrice', label: 'Comfort' }, { key: 'ladyPrice', label: 'Lady' }, { key: 'electricPrice', label: 'Electric' }, { key: 'vanPrice', label: 'Van' }, { key: 'deliveryPrice', label: 'Delivery' }, { key: 'mishwarVipPrice', label: 'Mishwar VIP' }, { key: 'awfarPrice', label: 'Awfar' }, ]; let tariffRows = []; async function renderTariffEditor(host) { host.innerHTML = '
Loading tariff…
'; try { const payload = await api('/ride/kazan/get.php'); tariffRows = Array.isArray(payload) ? payload : normaliseRows(payload); } catch (err) { if (handleApiError(err, 'tariff')) return; host.innerHTML = `
${esc(err.message)}
`; return; } if (!tariffRows.length) { host.innerHTML = '
No tariff rows configured.
'; return; } const readOnly = !isSuperAdmin(); host.innerHTML = ` ${readOnly ? `
You are signed in as an admin, so the tariff is shown read-only. Only a super admin can change prices.
` : `
These values are live. Saving changes what every passenger is charged from the next ride onwards. Changes are recorded in the audit log against your account.
`} ${tariffRows.map((row, index) => tariffCard(row, index, readOnly)).join('')}`; if (readOnly) return; host.querySelectorAll('[data-tariff-save]').forEach((btn) => btn.addEventListener('click', () => saveTariff(Number(btn.dataset.tariffSave), host))); host.querySelectorAll('[data-tariff-reset]').forEach((btn) => btn.addEventListener('click', () => renderTariffEditor(host))); } function tariffCard(row, index, readOnly) { const fields = TARIFF_FIELDS.filter((f) => row[f.key] !== undefined); return `

${esc(row.country || 'Tariff')} row #${esc(row.id)}

${readOnly ? '' : `
`}
${fields.map((f) => ` `).join('')}
`; } async function saveTariff(index, host) { if (!isSuperAdmin()) { toast('Only a super admin can change pricing.', 'warning'); return; } const row = tariffRows[index]; const inputs = host.querySelectorAll(`[data-tariff-input="${index}"]`); const changes = {}; inputs.forEach((input) => { const field = input.dataset.field; const current = String(row[field] ?? ''); const next = input.value.trim(); if (next !== current) changes[field] = next; }); if (!Object.keys(changes).length) { toast('Nothing changed on this tariff row.', 'info'); return; } const summary = Object.entries(changes) .map(([field, value]) => { const label = TARIFF_FIELDS.find((f) => f.key === field)?.label || field; return `• ${label}: ${row[field] ?? '—'} → ${value}`; }) .join('\n'); const confirmed = confirm( `Apply these pricing changes to "${row.country || 'tariff'}" (row #${row.id})?\n\n` + `${summary}\n\n` + 'This takes effect immediately for passengers.' ); if (!confirmed) return; try { await api('/ride/kazan/update.php', { params: { id: row.id, adminId: session?.id ?? '', ...changes }, }); toast('Tariff updated and recorded in the audit log.', 'success'); renderTariffEditor(host); } catch (err) { if (!handleApiError(err, 'tariff-save')) toast(`Update failed: ${err.message}`, 'danger'); } } 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(/status|state/i.test(k) ? labelStatus(v) : 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'); } // Outbound message — confirmed explicitly because it reaches a real person // and cannot be recalled. async function sendWhatsApp() { if (!isSuperAdmin()) { toast('Sending messages is restricted to super admins.', 'warning'); return; } const receiver = $('waReceiver').value.trim(); const message = $('waMessage').value.trim(); const status = $('waStatus'); if (!receiver || !message) { toast('Enter both a recipient and a message.', 'warning'); return; } if (!confirm(`Send this WhatsApp message to ${receiver}?\n\n${message}`)) return; status.textContent = 'Sending…'; try { await api('/Admin/send_whatsapp_message.php', { params: { receiver, message } }); status.textContent = `Sent to ${receiver}`; $('waMessage').value = ''; toast('Message sent.', 'success'); } catch (err) { status.textContent = ''; toast(`Send failed: ${err.message}`, 'danger'); } } // 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))); $('waSendBtn')?.addEventListener('click', sendWhatsApp); 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 || r.start_location], ['Drop-off', r.address_end || r.end_location], ['Distance', r.distance ? `${fmtNum(r.distance)} km` : '—'], ['Passenger fare', fmtMoney(rideFare(r))], ['Captain earning', r.price_for_driver ? fmtMoney(r.price_for_driver) : '—'], ['Payment method', r.paymentMethod], ['Service', r.carType], ['Requested', rideTimestamp(r)], ['Started', r.rideTimeStart ? fmtDate(r.rideTimeStart) : '—'], ['Finished', r.rideTimeFinish ? 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; } // Two status generations coexist in the ride table: the legacy CamelCase set // and the lowercase set written by the current ride pipeline. const STATUS_LABELS = { finished: 'Completed', completed: 'Completed', begin: 'In progress', started: 'In progress', apply: 'Captain assigned', applied: 'Captain assigned', accepted: 'Captain assigned', claimed: 'Captain assigned', arrived: 'Captain arrived', new: 'Waiting', nothing: 'Waiting', waiting: 'Waiting', wait: 'Waiting', pending: 'Waiting', searching: 'Searching for a captain', cancel: 'Cancelled', cancelfromdriver: 'Cancelled by captain', cancelfromdriverafterapply: 'Cancelled by captain', cancelfrompassenger: 'Cancelled by passenger', cancelled_by_driver: 'Cancelled by captain', cancelled_by_passenger: 'Cancelled by passenger', cancelled_no_driver_found: 'No captain found', timeout: 'Timed out', refused: 'Refused', pending_review: 'Pending review', }; function labelStatus(status) { if (!status) return 'Unknown'; return STATUS_LABELS[String(status).toLowerCase()] || humanize(status); } function badgeClass(status) { const s = String(status || '').toLowerCase(); if (['finished', 'completed', 'active', 'approved', 'online', 'success'].includes(s)) return 'badge-success'; if (s.startsWith('cancel') || ['timeout', 'refused', 'suspended', 'rejected', 'blocked', 'failure', 'error'].includes(s)) return 'badge-danger'; if (['begin', 'apply', 'applied', 'accepted', 'claimed', 'started', 'arrived'].includes(s)) return 'badge-primary'; if (['pending', 'pending_review', 'new', 'wait', 'waiting', 'nothing', 'searching'].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); } })();