diff --git a/dashboard/siro-admin/css/main.css b/dashboard/siro-admin/css/main.css
index 490b5adb..37febf03 100644
--- a/dashboard/siro-admin/css/main.css
+++ b/dashboard/siro-admin/css/main.css
@@ -1098,3 +1098,86 @@ h1, h2, h3, h4, h5, h6 {
.data-table .badge {
white-space: nowrap;
}
+
+/* Diagnostics panel */
+.card-note {
+ font-size: 0.82rem;
+ color: var(--text-muted);
+ line-height: 1.6;
+ margin-bottom: 1rem;
+}
+
+.api-base-row {
+ display: flex;
+ gap: 0.6rem;
+ flex-wrap: wrap;
+ align-items: center;
+}
+
+.api-base-row .select-input { flex: 1; min-width: 240px; }
+.api-base-row .form-input { flex: 1; min-width: 240px; padding-left: 1rem; }
+.api-base-row .btn-primary { width: auto; padding: 0.55rem 1.1rem; }
+
+.diagnostics {
+ margin: 0;
+ padding: 1rem;
+ max-height: 420px;
+ overflow: auto;
+ border-radius: var(--radius-md);
+ background: rgba(2, 6, 23, 0.75);
+ border: 1px solid var(--border-color);
+ color: var(--text-muted);
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 0.76rem;
+ line-height: 1.65;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+/* Generic module pages */
+.module-panels {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+}
+
+.panel-body { min-height: 40px; }
+
+.kpi-tiles {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
+ gap: 0.85rem;
+}
+
+.kpi-tile {
+ padding: 0.95rem 1.1rem;
+ border-radius: var(--radius-md);
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid var(--border-color);
+}
+
+.kpi-tile-value {
+ font-family: 'Outfit', sans-serif;
+ font-size: 1.5rem;
+ font-weight: 600;
+ color: var(--text-main);
+ line-height: 1.2;
+ word-break: break-word;
+}
+
+.kpi-tile-label {
+ margin-top: 0.3rem;
+ font-size: 0.78rem;
+ color: var(--text-muted);
+}
+
+.sub-panel { margin-top: 1.4rem; }
+
+.sub-panel-title {
+ font-size: 0.82rem;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--text-subtle);
+ margin-bottom: 0.75rem;
+}
diff --git a/dashboard/siro-admin/index.html b/dashboard/siro-admin/index.html
index 27a4acac..255fdcfe 100644
--- a/dashboard/siro-admin/index.html
+++ b/dashboard/siro-admin/index.html
@@ -470,8 +470,35 @@
+
+
+
+
+ The mobile admin app points at a different host per country. If every panel is empty,
+ the console is most likely calling a backend that does not exist on this domain.
+
+
+
+
+ Apply
+
+
+
+
+
+
Press “Run diagnostics” to call every endpoint and print the raw server reply.
+
diff --git a/dashboard/siro-admin/js/app.js b/dashboard/siro-admin/js/app.js
index e7e60375..a1a81b44 100644
--- a/dashboard/siro-admin/js/app.js
+++ b/dashboard/siro-admin/js/app.js
@@ -8,9 +8,22 @@
(() => {
'use strict';
- const API_BASE = '/backend';
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 }
@@ -29,9 +42,11 @@
deviceFingerprint = await resolveFingerprint();
if (el.fpPreview) el.fpPreview.textContent = deviceFingerprint.slice(0, 12) + '…';
+ buildModules();
setupNavigation();
setupAuthEvents();
setupDataEvents();
+ setupDiagnostics();
session = readSession();
if (session?.jwt) {
@@ -53,6 +68,8 @@
'driversTableBody', 'driversMeta', 'driversPrev', 'driversNext',
'passengersTableBody', 'approvalsTableBody',
'statusLegend', 'serviceMix', 'tripPerformance', 'sessionInfo',
+ 'apiBaseSelect', 'apiBaseCustom', 'saveApiBaseBtn', 'runDiagnosticsBtn',
+ 'copyDiagnosticsBtn', 'diagnosticsOutput',
].forEach((id) => { el[id] = $(id); });
}
@@ -263,6 +280,7 @@
localStorage.removeItem(SESSION_KEY);
session = null;
stats = null;
+ loadedModules.clear();
showLogin();
if (message) toast(message, 'info');
}
@@ -295,6 +313,10 @@
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');
}
});
}
@@ -571,6 +593,401 @@
`${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?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', 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;
+ 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) => `
+ `).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);
+ 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('No data returned.'));
+ return frag;
+ }
+
+ if (Array.isArray(payload)) {
+ frag.appendChild(payload.length ? buildTable(payload) : msgNode('No records.'));
+ 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) => `${esc(humanize(c))} `).join('')}
+
+ ${rows.slice(0, 100).map((row) => `${columns.map((c) => `${cellHtml(c, row[c])} `).join('')} `).join('')}
+
+
`;
+ 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] of PROBES) {
+ const url = API_BASE + path;
+ const started = performance.now();
+ try {
+ const res = await fetch(url, {
+ 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');
+ }
+
+ 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) => `${esc(c.label)} `)
+ .join('') + 'Custom… ';
+
+ 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);
+ 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 = [
@@ -787,6 +1204,9 @@
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'));