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 = ``;
+ return;
+ }
+
+ if (!drivers.length && offset === 0) {
+ host.innerHTML = 'No captains are awaiting document review.
';
+ return;
+ }
+
+ host.innerHTML = `
+
+
+
+
+ ID Name Phone
+
+ ${drivers.map((d) => `
+
+ #${esc(d.id)}
+ ${esc(`${d.first_name || ''} ${d.last_name || ''}`.trim() || 'Unnamed')}
+ ${esc(maskPhone(d.phone))}
+ Review documents
+ `).join('')}
+
+
+
+
+
+
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 = `
+
+
+
+
Documents (${documents.length})
+ ${documents.length ? `
+
+ ${documents.map((doc) => `
+
+ ${doc.link
+ ? `
+
+ `
+ : ' 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…
+
+ `;
+
+ $('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 = `
+
+ ${notes}
+ ${rows.length ? `
+
+
+ ID Name Phone Type Requested
+
+ ${rows.map((r) => `
+
+ #${esc(r.id)}
+ ${esc(r.name || '—')}
+ ${esc(maskPhone(r.phone))}
+ ${esc(r.type)}
+ ${esc(fmtDate(r.created_at, true))}
+
+ Activate
+
+ `).join('')}
+
+
+
` : (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 = '
';
+ renderPayload(panel.querySelector('.panel-body'), payload);
+ } catch (err) {
+ if (handleApiError(err, 'employees')) return;
+ panel.innerHTML = `${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.