From 67f55e5192fb85836061ba16c4970594a679d583 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Sat, 25 Jul 2026 14:30:49 +0300 Subject: [PATCH] Add driver document review and staff onboarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Driver documents: paged list from auth/driver/drivers_pending_list.php, a detail panel showing each uploaded document as a thumbnail linking to the full image, and activation via Admin/driver/updateDriverFromAdmin.php. The confirmation states how many documents were reviewed and warns explicitly when a captain has none on file, since approving then activates an unverified account. Details are requested as a POST body. The mobile app calls this endpoint as GET "?id=", which filterRequest() never reads, so its detail lookup cannot be receiving an id at all. Staff: pending admin/service accounts with per-account activation via Staff/activate.php, the employee list, and a creation form posting to Staff/add.php. Administrator accounts are offered only to super admins, matching add.php's own check; passwords are rejected below 8 characters and cleared from the form after submission. Both screens mask phone numbers for plain admins and never render token/password/fingerprint fields. Also stop .btn-primary stretching to full width when used inline in a card header — it is styled for the login form. Co-Authored-By: Claude Opus 5 --- dashboard/siro-admin/css/main.css | 58 ++++++ dashboard/siro-admin/js/app.js | 326 +++++++++++++++++++++++++++++- 2 files changed, 382 insertions(+), 2 deletions(-) diff --git a/dashboard/siro-admin/css/main.css b/dashboard/siro-admin/css/main.css index 944b8a96..d37c409c 100644 --- a/dashboard/siro-admin/css/main.css +++ b/dashboard/siro-admin/css/main.css @@ -1326,3 +1326,61 @@ h1, h2, h3, h4, h5, h6 { } textarea.form-input { text-align: start; } + +/* Driver document review */ +.doc-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 1rem; +} + +.doc-card { + margin: 0; + border-radius: var(--radius-md); + border: 1px solid var(--border-color); + background: rgba(255, 255, 255, 0.03); + overflow: hidden; +} + +.doc-card img { + display: block; + width: 100%; + height: 150px; + object-fit: cover; + background: rgba(2, 6, 23, 0.6); + transition: var(--transition-fast); +} + +.doc-card img:hover { opacity: 0.85; } + +.doc-missing { + display: flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + height: 150px; + color: var(--text-subtle); + font-size: 0.8rem; + background: rgba(2, 6, 23, 0.6); +} + +.doc-card figcaption { + padding: 0.6rem 0.75rem; + display: flex; + flex-direction: column; + gap: 0.15rem; + font-size: 0.8rem; + color: var(--text-main); + border-top: 1px solid var(--border-color); +} + +.doc-card figcaption .stamp { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* .btn-primary is full-width by default (login form); inline uses must not be */ +.btn-primary.btn-sm { + width: auto; + padding: 0.4rem 0.9rem; + justify-content: center; +} + +.card-header { gap: 1rem; flex-wrap: wrap; } diff --git a/dashboard/siro-admin/js/app.js b/dashboard/siro-admin/js/app.js index 7586c682..73b0b632 100644 --- a/dashboard/siro-admin/js/app.js +++ b/dashboard/siro-admin/js/app.js @@ -919,10 +919,15 @@ 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', - panels: [{ title: 'Employees', path: '/Admin/employee/get.php' }], + subtitle: 'Internal staff records, activation and onboarding', + custom: renderStaff, }, { id: 'audit', group: 'Administration', icon: 'ph-scroll', title: 'Audit Log', @@ -1053,6 +1058,323 @@ }); } + // ── 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.