diff --git a/dashboard/siro-admin/css/main.css b/dashboard/siro-admin/css/main.css index 37febf03..47910b18 100644 --- a/dashboard/siro-admin/css/main.css +++ b/dashboard/siro-admin/css/main.css @@ -1181,3 +1181,34 @@ h1, h2, h3, h4, h5, h6 { color: var(--text-subtle); margin-bottom: 0.75rem; } + +/* Inline search + clickable rows */ +.search-inline { + display: flex; + gap: 0.5rem; + align-items: center; + flex: 1; + min-width: 260px; +} + +.search-inline .form-input { + padding-left: 1rem; + font-size: 0.85rem; +} + +.row-clickable { cursor: pointer; } +.row-clickable:hover { background: rgba(99, 102, 241, 0.08) !important; } + +.load-more { + display: flex; + justify-content: center; + padding: 1rem 0 0.25rem; +} + +.decrypt-area { + padding: 0.85rem 1rem; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.8rem; + resize: vertical; + min-height: 84px; +} diff --git a/dashboard/siro-admin/index.html b/dashboard/siro-admin/index.html index 255fdcfe..e4d85d4d 100644 --- a/dashboard/siro-admin/index.html +++ b/dashboard/siro-admin/index.html @@ -286,6 +286,10 @@ +
+ + +
— @@ -308,6 +312,11 @@ +
+ +
@@ -325,6 +334,14 @@ +
+
+ + +
+ Click any row to open the full profile +
+
@@ -355,6 +372,14 @@ +
+
+ + +
+ Click any row to open the full profile +
+
@@ -474,6 +499,27 @@
+ +

API endpoint

diff --git a/dashboard/siro-admin/js/app.js b/dashboard/siro-admin/js/app.js index a1a81b44..1dba1d02 100644 --- a/dashboard/siro-admin/js/app.js +++ b/dashboard/siro-admin/js/app.js @@ -31,6 +31,9 @@ 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 = {}; @@ -67,6 +70,10 @@ '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', @@ -125,11 +132,27 @@ // ── API layer ──────────────────────────────────────────────────────────── // Every protected endpoint goes through connect.php → JwtService::authenticate, // which requires BOTH the bearer token and the X-Device-FP header. - async function api(path, { method = 'GET', body = null, auth = true } = {}) { + // 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}`; - const res = await fetch(API_BASE + path, { method, headers, body }); + 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; @@ -295,6 +318,7 @@ el.userName.textContent = session.name; el.userRole.textContent = formatRole(session.role); el.userAvatar.textContent = initials(session.name); + applyRoleVisibility(); } // ── Data loading ───────────────────────────────────────────────────────── @@ -336,12 +360,17 @@ 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?status=${encodeURIComponent(status)}`); - renderRides(Array.isArray(rides) ? rides : []); + 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; } } @@ -349,7 +378,7 @@ async function loadDrivers() { tableMessage(el.driversTableBody, 8, 'Loading captains…'); try { - const payload = await api(`/Admin/AdminCaptain/get.php?page=${driversPage}`); + const payload = await api('/Admin/AdminCaptain/get.php', { params: { page: driversPage } }); driversPages = payload.pages || 1; renderDrivers(payload.data || [], payload.total || 0); } catch (err) { @@ -465,8 +494,13 @@ `).join(''); } - function renderRides(rides) { - el.ridesMeta.textContent = `${rides.length} trip${rides.length === 1 ? '' : 's'}`; + 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; @@ -488,12 +522,134 @@ el.ridesTableBody.querySelectorAll('[data-ride]').forEach((btn) => { btn.addEventListener('click', () => { - const ride = rides.find((r) => String(r.id) === btn.dataset.ride); + 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) { @@ -501,10 +657,10 @@ return; } el.driversTableBody.innerHTML = drivers.map((d) => ` -
+ - + @@ -512,6 +668,9 @@ `).join(''); + + el.driversTableBody.querySelectorAll('[data-captain]').forEach((row) => + row.addEventListener('click', () => openCaptainProfile(row.dataset.captain))); } function renderPassengers(rows) { @@ -520,10 +679,10 @@ return; } el.passengersTableBody.innerHTML = rows.map((p) => ` - + - + @@ -531,6 +690,9 @@ `).join(''); + + el.passengersTableBody.querySelectorAll('[data-passenger]').forEach((row) => + row.addEventListener('click', () => openPassengerProfile(row.dataset.passenger))); } function renderApprovals(pending) { @@ -658,7 +820,7 @@ { 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' }], + 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', @@ -674,7 +836,7 @@ ], }, { - id: 'staff', group: 'Administration', icon: 'ph-identification-badge', title: 'Staff & Employees', + 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' }], }, @@ -709,6 +871,10 @@ 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); @@ -749,7 +915,7 @@ 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); + const payload = await api(p.path, { params: p.params || null }); renderPayload(body, payload); } catch (err) { if (handleApiError(err, mod.id)) return; @@ -773,12 +939,14 @@ const frag = document.createDocumentFragment(); if (payload === null || payload === undefined || payload === '') { - frag.appendChild(msgNode('No data returned.')); + 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.')); + frag.appendChild(payload.length + ? buildTable(payload) + : msgNode('No records recorded for this yet — the table is empty in the database.')); return frag; } @@ -891,8 +1059,8 @@ // 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'], + ['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'], @@ -912,11 +1080,18 @@ '─'.repeat(64), ]; - for (const [name, path] of PROBES) { + 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}` } : {}), @@ -946,6 +1121,30 @@ 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)'; } @@ -978,6 +1177,9 @@ }); 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); @@ -993,10 +1195,10 @@ const rows = [ ['Status', labelStatus(r.status)], ['Passenger', r.passenger_full_name], - ['Passenger phone', r.p_phone], + ['Passenger phone', maskPhone(r.p_phone)], ['Completed trips (passenger)', fmtInt(r.p_completed)], ['Captain', r.driver_full_name], - ['Captain phone', r.d_phone], + ['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], @@ -1223,6 +1425,17 @@ 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'); @@ -1238,6 +1451,18 @@ }); } + 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; });
#${esc(d.id)} ${esc(`${d.first_name || ''} ${d.last_name || ''}`.trim() || 'Unnamed')}${esc(d.phone || '—')}${esc(maskPhone(d.phone))} ${esc(d.email || '—')} ${rating(d.passengerAverageRating)} ${fmtInt(d.countPassengerRide)}${esc(d.status || 'unknown')}
#${esc(p.id)} ${esc(`${p.first_name || ''} ${p.last_name || ''}`.trim() || 'Unnamed')}${esc(p.email || p.phone || '—')}${esc(p.email || maskPhone(p.phone))} ${fmtInt(p.countPassengerRide)} ${rating(p.passengerAverageRating)} ${fmtInt(p.countPassengerCancel)}${esc(p.status || 'unknown')}