diff --git a/backend/Admin/notifications/broadcast.php b/backend/Admin/notifications/broadcast.php index 0768b136..077554c3 100644 --- a/backend/Admin/notifications/broadcast.php +++ b/backend/Admin/notifications/broadcast.php @@ -76,7 +76,9 @@ if (function_exists('logAudit')) { } // الاستدعاء الداخلي لخدمة FCM -$fcmUrl = getenv('FCM_INTERNAL_URL') ?: 'http://127.0.0.1/backend/ride/firebase/send_fcm.php'; +// من داخل حاوية php لا يوجد خادم ويب على 127.0.0.1 — الويب في حاوية nginx +// منفصلة، وتُعرف داخل شبكة Compose باسم الخدمة. هذا كان سبب فشل كل إشعار. +$fcmUrl = getenv('FCM_INTERNAL_URL') ?: 'http://nginx/backend/ride/firebase/send_fcm.php'; $payload = json_encode([ 'target' => $topic, 'title' => $title, @@ -106,8 +108,9 @@ $curlErr = curl_error($ch); curl_close($ch); if ($response === false || $httpCode >= 400) { - error_log("[Broadcast] FCM call failed (HTTP $httpCode): " . ($curlErr ?: $response)); - jsonError("Notification service rejected the request (HTTP $httpCode).", 502); + $reason = $curlErr ?: (is_string($response) ? substr($response, 0, 200) : 'no response'); + error_log("[Broadcast] FCM call failed (HTTP $httpCode) via $fcmUrl: $reason"); + jsonError("Notification service unreachable at $fcmUrl — $reason", 502); } $decoded = json_decode((string) $response, true); diff --git a/dashboard/siro-admin/index.html b/dashboard/siro-admin/index.html index 281f3770..756f0e4c 100644 --- a/dashboard/siro-admin/index.html +++ b/dashboard/siro-admin/index.html @@ -15,7 +15,7 @@ - + @@ -609,6 +609,6 @@ - + diff --git a/dashboard/siro-admin/js/app.js b/dashboard/siro-admin/js/app.js index fc843700..87b424b6 100644 --- a/dashboard/siro-admin/js/app.js +++ b/dashboard/siro-admin/js/app.js @@ -11,7 +11,7 @@ // Bump together with the ?v= query in index.html. Shown in the UI and in the // diagnostics report so "the deploy did nothing" can be answered with a fact // rather than a guess about caching. - const BUILD = '2026-07-25-8'; + const BUILD = '2026-07-25-9'; // ── Localisation ───────────────────────────────────────────────────────── // Arabic is the operators' language; English is kept because several screens @@ -1120,8 +1120,8 @@ }, { id: 'transit', group: 'Transit', icon: 'ph-bus', title: 'Mawasalati Organisations', - subtitle: 'Registered transit organisations', - panels: [{ title: 'Organisations', path: '/Admin/transit/org/list.php' }], + subtitle: 'Universities, schools and companies running their own transport — open one to manage it', + custom: renderTransitOrgs, }, { id: 'routes', group: 'Transit', icon: 'ph-path', title: 'Route Approvals', @@ -1424,6 +1424,316 @@ } } + + // ── Transit organisations ──────────────────────────────────────────────── + // A list of counts is not actionable on its own: an operator needs to open + // an organisation, see its routes and trips, adjust the contract, and manage + // who administers it. + const ORG_TYPES = ['university', 'school', 'hotel', 'company', 'transporter']; + const CONTRACT_STATES = ['trial', 'active', 'suspended', 'expired']; + + async function renderTransitOrgs(host) { + host.innerHTML = ` +
+
+ + +
+ +
+
Loading organisations…
`; + + $('orgNewBtn').addEventListener('click', () => openOrgForm(null, host)); + bindSearch($('orgSearch'), $('orgSearchBtn'), + (term) => loadOrgs(host, term), () => loadOrgs(host, '')); + + loadOrgs(host, ''); + } + + async function loadOrgs(host, search) { + const panel = $('orgList'); + panel.innerHTML = '
Loading organisations…
'; + try { + const payload = await api('/Admin/transit/org/list.php', { params: { search } }); + const orgs = payload?.orgs || []; + const total = payload?.pagination?.total ?? orgs.length; + + if (!orgs.length) { + panel.innerHTML = '
No organisations registered yet.
'; + return; + } + + panel.innerHTML = ` +
+

Organisations ${fmtInt(total)} total

+
+
+ + + + + + + ${orgs.map((o, i) => ` + + + + + + + + + + + `).join('')} + +
NameTypeCityContractCaptainsVehiclesRoutesEnrolled
${esc(o.name_ar || o.name_en || '—')}${esc(humanize(o.type || '—'))}${esc(o.city || '—')}${esc(humanize(o.contract_status || 'unknown'))}${fmtInt(o.drivers_count)}${fmtInt(o.vehicles_count)}${fmtInt(o.active_routes)}${fmtInt(o.active_enrollments)}
+
`; + + panel.querySelectorAll('[data-org]').forEach((row) => + row.addEventListener('click', (e) => { + if (e.target.closest('[data-org-edit]')) return; + openOrgDetails(row.dataset.org, host); + })); + panel.querySelectorAll('[data-org-edit]').forEach((btn) => + btn.addEventListener('click', () => openOrgForm(orgs[Number(btn.dataset.orgEdit)], host))); + } catch (err) { + if (handleApiError(err, 'transit-orgs')) return; + panel.innerHTML = `
${esc(err.message)}
`; + } + } + + async function openOrgDetails(orgId, host) { + const body = $('modalBodyContent'); + body.innerHTML = '
Loading…
'; + $('detailsModal').classList.add('active'); + + try { + const payload = await api('/Admin/transit/org/details.php', { params: { org_id: orgId } }); + const org = payload?.org || {}; + const counts = payload?.counts || {}; + const routes = payload?.routes || []; + const trips = payload?.trips || []; + + body.innerHTML = ` + + +
+ ${Object.entries(counts).map(([k, v]) => + `
${esc(formatValue(v, k))}
${esc(humanize(k))}
`).join('')} +
+ +
+

Routes (${routes.length})

+ ${routes.length ? `
${routes.map((r) => ` +
+ ${esc(r.name_ar || r.name_en || `#${r.id}`)} + ${esc(humanize(r.status || ''))} +
`).join('')}
` : '
No routes yet.
'} +
+ +
+

Recent trips (${trips.length})

+
+
+ +
+

Administrators

+
Loading…
+
+ + + +
+
`; + + renderPayload($('orgTrips'), trips); + loadOrgAdmins(orgId); + $('orgAdminAdd').addEventListener('click', () => addOrgAdmin(orgId)); + } catch (err) { + if (handleApiError(err, 'org-details')) return; + body.innerHTML = ` +
${esc(err.message)}
`; + } + } + + async function loadOrgAdmins(orgId) { + const panel = $('orgAdmins'); + if (!panel) return; + try { + const payload = await api('/Admin/transit/org/admins_list.php', { params: { org_id: orgId } }); + const admins = payload?.admins || []; + if (!admins.length) { + panel.innerHTML = '
No administrators yet.
'; + return; + } + panel.innerHTML = `
${admins.map((a) => ` +
+ ${esc(a.name || '—')} ${esc(humanize(a.role || ''))} + + + +
`).join('')}
`; + + panel.querySelectorAll('[data-admin-toggle]').forEach((btn) => + btn.addEventListener('click', () => toggleOrgAdmin(btn.dataset.adminToggle, Number(btn.dataset.active) ? 0 : 1, orgId))); + } catch (err) { + if (handleApiError(err, 'org-admins')) return; + panel.innerHTML = `
${esc(err.message)}
`; + } + } + + async function addOrgAdmin(orgId) { + const name = $('orgAdminName').value.trim(); + const phone = $('orgAdminPhone').value.trim(); + if (!name || !phone) { + toast('Enter both a name and a phone number.', 'warning'); + return; + } + if (!confirm(`Give ${name} (${phone}) administrator access to this organisation?`)) return; + try { + await api('/Admin/transit/org/admin_add.php', { params: { org_id: orgId, name, phone } }); + toast('Administrator added.', 'success'); + $('orgAdminName').value = ''; + $('orgAdminPhone').value = ''; + loadOrgAdmins(orgId); + } catch (err) { + if (!handleApiError(err, 'org-admin-add')) toast(err.message, 'danger'); + } + } + + async function toggleOrgAdmin(adminId, isActive, orgId) { + if (!confirm(isActive ? 'Enable this administrator?' : 'Disable this administrator?')) return; + try { + await api('/Admin/transit/org/admin_toggle.php', { params: { admin_id: adminId, is_active: isActive } }); + toast(isActive ? 'Administrator enabled.' : 'Administrator disabled.', 'success'); + loadOrgAdmins(orgId); + } catch (err) { + if (!handleApiError(err, 'org-admin-toggle')) toast(err.message, 'danger'); + } + } + + // create.php needs the founding administrator; update.php only takes the + // fields it knows, so the form differs between the two modes. + function openOrgForm(org, host) { + const editing = !!org; + const body = $('modalBodyContent'); + + body.innerHTML = ` + +
+ ${editing ? '' : ` + + + + + + `} + + ${editing ? ` + + + + ` : ''} +
+
+ +
`; + + $('detailsModal').classList.add('active'); + $('orgSave').addEventListener('click', () => saveOrg(org, host)); + } + + async function saveOrg(org, host) { + const editing = !!org; + try { + if (editing) { + const params = { + org_id: org.id, + city: $('orgCity').value.trim(), + contact_email: $('orgEmail').value.trim(), + website: $('orgWebsite').value.trim(), + contract_status: $('orgContract').value, + trial_ends_at: $('orgTrialEnds').value, + }; + if (params.contract_status !== org.contract_status && + !confirm(`Change the contract from "${org.contract_status}" to "${params.contract_status}"? ` + + 'Suspending stops the organisation using the service.')) return; + + await api('/Admin/transit/org/update.php', { params }); + toast('Organisation updated.', 'success'); + } else { + const params = { + type: $('orgType').value, + name_ar: $('orgNameAr').value.trim(), + name_en: $('orgNameEn').value.trim(), + country: $('orgCountry').value.trim().toUpperCase(), + city: $('orgCity').value.trim(), + admin_name: $('orgAdminNameNew').value.trim(), + admin_phone: $('orgAdminPhoneNew').value.trim(), + }; + if (!params.name_ar || !params.admin_name || !params.admin_phone) { + toast('Name, founding admin name and phone are required.', 'warning'); + return; + } + await api('/Admin/transit/org/create.php', { params }); + toast('Organisation created.', 'success'); + } + closeModal(); + loadOrgs(host, $('orgSearch')?.value.trim() || ''); + } catch (err) { + if (!handleApiError(err, 'org-save')) toast(err.message, 'danger'); + } + } + // ── Blacklist & removal ────────────────────────────────────────────────── // Deletion here is a real DELETE against passengers/driver — the account and // its login are gone. The console therefore demands the phone number be