Update: 2026-07-12 05:40:28
This commit is contained in:
@@ -14,6 +14,23 @@ DB_NAME=siro_main
|
|||||||
DB_USER=siro_user
|
DB_USER=siro_user
|
||||||
DB_PASS=<CHANGE_ME_STRONG_PASSWORD>
|
DB_PASS=<CHANGE_ME_STRONG_PASSWORD>
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Database Configuration - TRANSIT DATABASE (مواصلاتي — جامعات/مدارس/فنادق/شركات)
|
||||||
|
# =============================================================================
|
||||||
|
# قاعدة بيانات معزولة تماماً عن main/ride/tracking — ممنوع أي JOIN بينها وبينهم،
|
||||||
|
# الربط بينها وبين النظام الرئيسي عبر المعرّفات (passenger_id/driver_id) فقط.
|
||||||
|
DB_TRANSIT_HOST=localhost
|
||||||
|
DB_TRANSIT_PORT=3306
|
||||||
|
DB_TRANSIT_NAME=siroTransitDb
|
||||||
|
DB_TRANSIT_USER=siroTransitUser
|
||||||
|
DB_TRANSIT_PASS=<CHANGE_ME_STRONG_PASSWORD>
|
||||||
|
# مفتاح تشفير الرقم الجامعي (32 byte) — منفصل عن ENC_KEY لمزيد من العزل
|
||||||
|
TRANSIT_STUDENT_ID_KEY=<CHANGE_ME_32_CHAR_KEY>
|
||||||
|
# Origins مسموحة للوحة الويب (مشرف المؤسسة)
|
||||||
|
TRANSIT_ADMIN_ORIGINS=https://transit.siromove.com,https://admin.siromove.com
|
||||||
|
# رابط تفعيل السائق (deep link في تطبيق السائق)
|
||||||
|
APP_DEEP_LINK_BASE=https://siromove.com/driver/transit-activate
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Encryption Configuration - CRITICAL FOR SECURITY
|
# Encryption Configuration - CRITICAL FOR SECURITY
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
// Admin/transit/org/admin_add.php — فريق سيرو يضيف مشرفاً جديداً لمؤسسة قائمة
|
||||||
|
// POST: org_id, name, phone, role? (owner|transport_manager|dispatcher)
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../connect.php';
|
||||||
|
|
||||||
|
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||||
|
jsonError('Unauthorized: Admin access required', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
try { $transit_con = Database::get('transit'); }
|
||||||
|
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../transit/functions.php';
|
||||||
|
|
||||||
|
requireTransitFields(['org_id', 'name', 'phone']);
|
||||||
|
|
||||||
|
$orgId = filterRequest('org_id', 'int');
|
||||||
|
$name = filterRequest('name');
|
||||||
|
$phone = normalizePhone(filterRequest('phone'));
|
||||||
|
$adminRole = filterRequest('role') ?: 'transport_manager';
|
||||||
|
|
||||||
|
$allowedRoles = ['owner', 'transport_manager', 'dispatcher'];
|
||||||
|
if (!in_array($adminRole, $allowedRoles)) jsonError('Invalid role', 400);
|
||||||
|
|
||||||
|
$chkOrg = $transit_con->prepare("SELECT id FROM transit_orgs WHERE id=? LIMIT 1");
|
||||||
|
$chkOrg->execute([$orgId]);
|
||||||
|
if (!$chkOrg->fetch()) jsonError('Organization not found', 404);
|
||||||
|
|
||||||
|
$phoneEnc = $encryptionHelper->encryptData($phone);
|
||||||
|
|
||||||
|
$chkDup = $transit_con->prepare(
|
||||||
|
"SELECT id FROM transit_org_admins WHERE org_id=? AND phone=? LIMIT 1"
|
||||||
|
);
|
||||||
|
$chkDup->execute([$orgId, $phoneEnc]);
|
||||||
|
if ($chkDup->fetch()) jsonError('An admin with this phone already exists for this organization', 409);
|
||||||
|
|
||||||
|
$transit_con->prepare(
|
||||||
|
"INSERT INTO transit_org_admins (org_id, name, phone, role, is_active) VALUES (?,?,?,?,1)"
|
||||||
|
)->execute([$orgId, $name, $phoneEnc, $adminRole]);
|
||||||
|
|
||||||
|
appLog("[ADMIN][TRANSIT][ORG][admin_add] org={$orgId} name={$name} role={$adminRole}");
|
||||||
|
|
||||||
|
jsonSuccess(['admin_id' => (int)$transit_con->lastInsertId()], 'Admin added successfully');
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
// Admin/transit/org/admin_toggle.php — تفعيل/تعليق مشرف مؤسسة (لفريق سيرو)
|
||||||
|
// POST: admin_id, is_active (1|0)
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../connect.php';
|
||||||
|
|
||||||
|
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||||
|
jsonError('Unauthorized: Admin access required', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
try { $transit_con = Database::get('transit'); }
|
||||||
|
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
|
||||||
|
|
||||||
|
$adminId = filterRequest('admin_id', 'int');
|
||||||
|
$isActive = filterRequest('is_active', 'int');
|
||||||
|
|
||||||
|
if (!$adminId || $isActive === null) jsonError('admin_id and is_active are required', 400);
|
||||||
|
|
||||||
|
$st = $transit_con->prepare("SELECT id, org_id FROM transit_org_admins WHERE id=? LIMIT 1");
|
||||||
|
$st->execute([$adminId]);
|
||||||
|
$admin = $st->fetch();
|
||||||
|
if (!$admin) jsonError('Admin not found', 404);
|
||||||
|
|
||||||
|
$transit_con->prepare("UPDATE transit_org_admins SET is_active=? WHERE id=?")
|
||||||
|
->execute([$isActive ? 1 : 0, $adminId]);
|
||||||
|
|
||||||
|
// إبطال جلساته الحالية فوراً عند التعليق
|
||||||
|
if (!$isActive) {
|
||||||
|
$sessions = $transit_con->prepare("SELECT token_hash FROM transit_sessions WHERE admin_id=?");
|
||||||
|
$sessions->execute([$adminId]);
|
||||||
|
foreach ($sessions->fetchAll(PDO::FETCH_COLUMN) as $hash) {
|
||||||
|
if ($redis) $redis->del("transit:session:{$hash}");
|
||||||
|
}
|
||||||
|
$transit_con->prepare("DELETE FROM transit_sessions WHERE admin_id=?")->execute([$adminId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
appLog("[ADMIN][TRANSIT][ORG][admin_toggle] admin={$adminId} is_active={$isActive}");
|
||||||
|
|
||||||
|
jsonSuccess(['admin_id' => $adminId, 'is_active' => (bool)$isActive]);
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
// Admin/transit/org/admins_list.php — قائمة مشرفي مؤسسة (لفريق سيرو)
|
||||||
|
// POST/GET: org_id
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../connect.php';
|
||||||
|
|
||||||
|
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||||
|
jsonError('Unauthorized: Admin access required', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
try { $transit_con = Database::get('transit'); }
|
||||||
|
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
|
||||||
|
|
||||||
|
$orgId = filterRequest('org_id', 'int');
|
||||||
|
if (!$orgId) jsonError('org_id is required', 400);
|
||||||
|
|
||||||
|
$st = $transit_con->prepare(
|
||||||
|
"SELECT id, name, phone, role, is_active, created_at
|
||||||
|
FROM transit_org_admins WHERE org_id=? ORDER BY created_at ASC"
|
||||||
|
);
|
||||||
|
$st->execute([$orgId]);
|
||||||
|
$admins = $st->fetchAll();
|
||||||
|
|
||||||
|
foreach ($admins as &$a) {
|
||||||
|
if (!empty($a['phone'])) {
|
||||||
|
$a['phone'] = $encryptionHelper->decryptData($a['phone']) ?: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($a);
|
||||||
|
|
||||||
|
jsonSuccess(['admins' => $admins]);
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
// Admin/transit/org/create.php — فريق سيرو يضيف مؤسسة جديدة (جامعة/مدرسة/فندق/شركة/ناقل)
|
||||||
|
// + ينشئ أول مشرف (owner) لها مباشرة
|
||||||
|
// POST: type, country, city, name_ar, name_en, admin_name, admin_phone, ...
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../connect.php';
|
||||||
|
|
||||||
|
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||||
|
jsonError('Unauthorized: Admin access required', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
try { $transit_con = Database::get('transit'); }
|
||||||
|
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../transit/functions.php';
|
||||||
|
|
||||||
|
requireTransitFields(['type', 'country', 'city', 'name_ar', 'name_en', 'admin_name', 'admin_phone']);
|
||||||
|
|
||||||
|
$type = filterRequest('type');
|
||||||
|
$country = strtoupper(substr(filterRequest('country'), 0, 2));
|
||||||
|
$city = filterRequest('city');
|
||||||
|
$nameAr = filterRequest('name_ar');
|
||||||
|
$nameEn = filterRequest('name_en');
|
||||||
|
$adminName = filterRequest('admin_name');
|
||||||
|
$adminPhone = normalizePhone(filterRequest('admin_phone'));
|
||||||
|
$adminRole = filterRequest('admin_role') ?: 'owner';
|
||||||
|
$trialEndsAt = filterRequest('trial_ends_at') ?: date('Y-m-d', strtotime('+90 days'));
|
||||||
|
|
||||||
|
$allowedTypes = ['university', 'school', 'hotel', 'company', 'transporter'];
|
||||||
|
if (!in_array($type, $allowedTypes)) {
|
||||||
|
jsonError('Invalid type. Allowed: ' . implode(', ', $allowedTypes));
|
||||||
|
}
|
||||||
|
|
||||||
|
$chk = $transit_con->prepare("SELECT id FROM transit_orgs WHERE name_ar=? AND country=? LIMIT 1");
|
||||||
|
$chk->execute([$nameAr, $country]);
|
||||||
|
if ($chk->fetch()) jsonError('Organization already exists', 409);
|
||||||
|
|
||||||
|
$adminPhoneEnc = $encryptionHelper->encryptData($adminPhone);
|
||||||
|
$contactPhoneRaw = normalizePhone(filterRequest('contact_phone') ?? '');
|
||||||
|
$contactPhoneEnc = $contactPhoneRaw ? $encryptionHelper->encryptData($contactPhoneRaw) : null;
|
||||||
|
|
||||||
|
$transit_con->beginTransaction();
|
||||||
|
try {
|
||||||
|
$transit_con->prepare(
|
||||||
|
"INSERT INTO transit_orgs
|
||||||
|
(type, country, city, name_ar, name_en, contact_phone, contact_email, website, contract_status, trial_ends_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,'active',?)"
|
||||||
|
)->execute([
|
||||||
|
$type, $country, $city, $nameAr, $nameEn,
|
||||||
|
$contactPhoneEnc, filterRequest('contact_email'), filterRequest('website'),
|
||||||
|
$trialEndsAt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$orgId = (int)$transit_con->lastInsertId();
|
||||||
|
|
||||||
|
$transit_con->prepare(
|
||||||
|
"INSERT INTO transit_org_admins (org_id, name, phone, role) VALUES (?,?,?,?)"
|
||||||
|
)->execute([$orgId, $adminName, $adminPhoneEnc, $adminRole]);
|
||||||
|
|
||||||
|
$transit_con->commit();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$transit_con->rollBack();
|
||||||
|
appLog('[ADMIN][TRANSIT][ORG][create] ' . $e->getMessage(), 'ERROR');
|
||||||
|
jsonError('Failed to create organization', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonSuccess([
|
||||||
|
'org_id' => $orgId,
|
||||||
|
'name_ar' => $nameAr,
|
||||||
|
'type' => $type,
|
||||||
|
'country' => $country,
|
||||||
|
], 'Organization created successfully');
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
// Admin/transit/org/details.php — تفاصيل وتحليلات مؤسسة واحدة (لفريق سيرو)
|
||||||
|
// POST/GET: org_id
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../connect.php';
|
||||||
|
|
||||||
|
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||||
|
jsonError('Unauthorized: Admin access required', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
try { $transit_con = Database::get('transit'); }
|
||||||
|
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
|
||||||
|
|
||||||
|
$orgId = filterRequest('org_id', 'int');
|
||||||
|
if (!$orgId) jsonError('org_id is required', 400);
|
||||||
|
|
||||||
|
$st = $transit_con->prepare("SELECT * FROM transit_orgs WHERE id=? LIMIT 1");
|
||||||
|
$st->execute([$orgId]);
|
||||||
|
$org = $st->fetch();
|
||||||
|
if (!$org) jsonError('Organization not found', 404);
|
||||||
|
|
||||||
|
// فك تشفير هاتف التواصل للعرض الإداري فقط
|
||||||
|
if (!empty($org['contact_phone'])) {
|
||||||
|
$org['contact_phone'] = $encryptionHelper->decryptData($org['contact_phone']) ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── العدّادات الأساسية ───────────────────────────────────────
|
||||||
|
$counts = [
|
||||||
|
'drivers_total' => (int)$transit_con->query("SELECT COUNT(*) FROM transit_drivers WHERE org_id=$orgId")->fetchColumn(),
|
||||||
|
'drivers_active' => (int)$transit_con->query("SELECT COUNT(*) FROM transit_drivers WHERE org_id=$orgId AND status='active'")->fetchColumn(),
|
||||||
|
'vehicles_total' => (int)$transit_con->query("SELECT COUNT(*) FROM transit_vehicles WHERE org_id=$orgId")->fetchColumn(),
|
||||||
|
'vehicles_active' => (int)$transit_con->query("SELECT COUNT(*) FROM transit_vehicles WHERE org_id=$orgId AND is_active=1")->fetchColumn(),
|
||||||
|
'routes_total' => (int)$transit_con->query("SELECT COUNT(*) FROM transit_routes WHERE org_id=$orgId")->fetchColumn(),
|
||||||
|
'routes_active' => (int)$transit_con->query("SELECT COUNT(*) FROM transit_routes WHERE org_id=$orgId AND status='active'")->fetchColumn(),
|
||||||
|
'enrollments_active' => (int)$transit_con->query("SELECT COUNT(*) FROM transit_enrollments WHERE org_id=$orgId AND status='active'")->fetchColumn(),
|
||||||
|
'enrollments_pending' => (int)$transit_con->query("SELECT COUNT(*) FROM transit_enrollments WHERE org_id=$orgId AND status='pending'")->fetchColumn(),
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── الرحلات: اليوم / هذا الأسبوع / هذا الشهر / إجمالي ──────────
|
||||||
|
$today = date('Y-m-d');
|
||||||
|
$weekStart = date('Y-m-d', strtotime('monday this week'));
|
||||||
|
$monthStart = date('Y-m-01');
|
||||||
|
|
||||||
|
$stTrips = $transit_con->prepare(
|
||||||
|
"SELECT
|
||||||
|
SUM(trip_date = ?) AS today_count,
|
||||||
|
SUM(trip_date >= ?) AS week_count,
|
||||||
|
SUM(trip_date >= ?) AS month_count,
|
||||||
|
COUNT(*) AS total_count,
|
||||||
|
SUM(status='completed') AS completed_count,
|
||||||
|
SUM(status='cancelled') AS cancelled_count,
|
||||||
|
SUM(status='no_show') AS no_show_count,
|
||||||
|
AVG(CASE WHEN status='completed' THEN delay_minutes END) AS avg_delay_minutes,
|
||||||
|
SUM(CASE WHEN status='completed' AND started_at IS NOT NULL AND completed_at IS NOT NULL
|
||||||
|
THEN TIMESTAMPDIFF(MINUTE, started_at, completed_at) ELSE 0 END) AS total_minutes_driven
|
||||||
|
FROM transit_trips WHERE org_id = ?"
|
||||||
|
);
|
||||||
|
$stTrips->execute([$today, $weekStart, $monthStart, $orgId]);
|
||||||
|
$tripStats = $stTrips->fetch();
|
||||||
|
|
||||||
|
$trips = [
|
||||||
|
'today' => (int)($tripStats['today_count'] ?? 0),
|
||||||
|
'this_week' => (int)($tripStats['week_count'] ?? 0),
|
||||||
|
'this_month' => (int)($tripStats['month_count'] ?? 0),
|
||||||
|
'total' => (int)($tripStats['total_count'] ?? 0),
|
||||||
|
'completed' => (int)($tripStats['completed_count'] ?? 0),
|
||||||
|
'cancelled' => (int)($tripStats['cancelled_count'] ?? 0),
|
||||||
|
'no_show' => (int)($tripStats['no_show_count'] ?? 0),
|
||||||
|
'avg_delay_minutes' => round((float)($tripStats['avg_delay_minutes'] ?? 0), 1),
|
||||||
|
'total_hours_driven' => round(((int)($tripStats['total_minutes_driven'] ?? 0)) / 60, 1),
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── الخطوط مع ملخص لكل خط ─────────────────────────────────────
|
||||||
|
$stRoutes = $transit_con->prepare(
|
||||||
|
"SELECT r.id, r.name_ar, r.status, r.distance_km,
|
||||||
|
(SELECT COUNT(*) FROM transit_stops s WHERE s.route_id = r.id) AS stops_count,
|
||||||
|
(SELECT COUNT(*) FROM transit_trips t WHERE t.route_id = r.id AND t.status='completed') AS completed_trips
|
||||||
|
FROM transit_routes r WHERE r.org_id = ? ORDER BY r.name_ar ASC"
|
||||||
|
);
|
||||||
|
$stRoutes->execute([$orgId]);
|
||||||
|
$routes = $stRoutes->fetchAll();
|
||||||
|
|
||||||
|
jsonSuccess([
|
||||||
|
'org' => $org,
|
||||||
|
'counts' => $counts,
|
||||||
|
'trips' => $trips,
|
||||||
|
'routes' => $routes,
|
||||||
|
]);
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
// Admin/transit/org/list.php — قائمة كل مؤسسات مواصلاتي (لفريق سيرو)
|
||||||
|
// GET/POST: country?, type?, contract_status?, search?, page?, per_page?
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../connect.php';
|
||||||
|
|
||||||
|
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||||
|
jsonError('Unauthorized: Admin access required', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
try { $transit_con = Database::get('transit'); }
|
||||||
|
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
|
||||||
|
|
||||||
|
$country = filterRequest('country');
|
||||||
|
$type = filterRequest('type');
|
||||||
|
$contractStatus = filterRequest('contract_status');
|
||||||
|
$search = filterRequest('search');
|
||||||
|
$page = max(1, (int)(filterRequest('page', 'int') ?? 1));
|
||||||
|
$perPage = min(100, max(10, (int)(filterRequest('per_page', 'int') ?? 30)));
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
|
||||||
|
$where = '1=1';
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($country) { $where .= ' AND country = ?'; $params[] = strtoupper(substr($country, 0, 2)); }
|
||||||
|
if ($type) { $where .= ' AND type = ?'; $params[] = $type; }
|
||||||
|
if ($contractStatus) { $where .= ' AND contract_status = ?'; $params[] = $contractStatus; }
|
||||||
|
if ($search) { $where .= ' AND (name_ar LIKE ? OR name_en LIKE ?)'; $params[] = "%$search%"; $params[] = "%$search%"; }
|
||||||
|
|
||||||
|
$countSt = $transit_con->prepare("SELECT COUNT(*) FROM transit_orgs WHERE $where");
|
||||||
|
$countSt->execute($params);
|
||||||
|
$total = (int)$countSt->fetchColumn();
|
||||||
|
|
||||||
|
$params[] = $perPage;
|
||||||
|
$params[] = $offset;
|
||||||
|
$st = $transit_con->prepare(
|
||||||
|
"SELECT id, type, country, city, name_ar, name_en, logo_url,
|
||||||
|
contract_status, trial_ends_at, created_at
|
||||||
|
FROM transit_orgs
|
||||||
|
WHERE $where
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ? OFFSET ?"
|
||||||
|
);
|
||||||
|
$st->execute($params);
|
||||||
|
$orgs = $st->fetchAll();
|
||||||
|
|
||||||
|
if ($orgs) {
|
||||||
|
$ids = implode(',', array_map('intval', array_column($orgs, 'id')));
|
||||||
|
|
||||||
|
$drivers = $transit_con->query("SELECT org_id, COUNT(*) c FROM transit_drivers WHERE org_id IN ($ids) GROUP BY org_id")
|
||||||
|
->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||||
|
$vehicles = $transit_con->query("SELECT org_id, COUNT(*) c FROM transit_vehicles WHERE org_id IN ($ids) GROUP BY org_id")
|
||||||
|
->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||||
|
$routes = $transit_con->query("SELECT org_id, COUNT(*) c FROM transit_routes WHERE org_id IN ($ids) AND status='active' GROUP BY org_id")
|
||||||
|
->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||||
|
$enrollments = $transit_con->query("SELECT org_id, COUNT(*) c FROM transit_enrollments WHERE org_id IN ($ids) AND status='active' GROUP BY org_id")
|
||||||
|
->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||||
|
|
||||||
|
foreach ($orgs as &$o) {
|
||||||
|
$id = $o['id'];
|
||||||
|
$o['drivers_count'] = (int)($drivers[$id] ?? 0);
|
||||||
|
$o['vehicles_count'] = (int)($vehicles[$id] ?? 0);
|
||||||
|
$o['active_routes'] = (int)($routes[$id] ?? 0);
|
||||||
|
$o['active_enrollments'] = (int)($enrollments[$id] ?? 0);
|
||||||
|
}
|
||||||
|
unset($o);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonSuccess([
|
||||||
|
'orgs' => $orgs,
|
||||||
|
'pagination' => ['total' => $total, 'page' => $page, 'per_page' => $perPage],
|
||||||
|
]);
|
||||||
@@ -28,6 +28,12 @@ class Database
|
|||||||
'user' => 'DB_RIDE_USER',
|
'user' => 'DB_RIDE_USER',
|
||||||
'pass' => 'DB_RIDE_PASS',
|
'pass' => 'DB_RIDE_PASS',
|
||||||
],
|
],
|
||||||
|
'transit' => [
|
||||||
|
'name' => 'DB_TRANSIT_NAME',
|
||||||
|
'host' => 'DB_TRANSIT_HOST',
|
||||||
|
'user' => 'DB_TRANSIT_USER',
|
||||||
|
'pass' => 'DB_TRANSIT_PASS',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
public static function get(string $name = 'main'): PDO
|
public static function get(string $name = 'main'): PDO
|
||||||
|
|||||||
@@ -120,6 +120,55 @@ class FcmService
|
|||||||
: ['status' => 'error', 'code' => $httpCode, 'response' => $result];
|
: ['status' => 'error', 'code' => $httpCode, 'response' => $result];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── إرسال إشعار لـ FCM Topic (قناة المواصلاتي وغيرها) ──
|
||||||
|
public function sendToTopic(
|
||||||
|
string $topic,
|
||||||
|
string $title,
|
||||||
|
string $body,
|
||||||
|
array $data = []
|
||||||
|
): array {
|
||||||
|
$accessToken = $this->getAccessToken();
|
||||||
|
if (!$accessToken) return ['status' => 'error', 'message' => 'No access token'];
|
||||||
|
|
||||||
|
if (!file_exists($this->serviceAccountFile)) {
|
||||||
|
return ['status' => 'error', 'message' => 'Service account file missing'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$creds = json_decode(file_get_contents($this->serviceAccountFile), true);
|
||||||
|
$projectId = $creds['project_id'];
|
||||||
|
$fcmUrl = "https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send";
|
||||||
|
|
||||||
|
$processedData = array_map(
|
||||||
|
fn($v) => is_array($v) || is_object($v) ? json_encode($v, JSON_UNESCAPED_UNICODE) : (string)$v,
|
||||||
|
array_merge($data, ['title' => $title, 'body' => $body])
|
||||||
|
);
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'message' => [
|
||||||
|
'topic' => $topic,
|
||||||
|
'notification' => ['title' => $title, 'body' => $body],
|
||||||
|
'data' => $processedData,
|
||||||
|
'android' => ['priority' => 'HIGH'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$ch = curl_init($fcmUrl);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_HTTPHEADER => ["Authorization: Bearer $accessToken", 'Content-Type: application/json; charset=UTF-8'],
|
||||||
|
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 5,
|
||||||
|
]);
|
||||||
|
$result = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
return $httpCode === 200
|
||||||
|
? ['status' => 'success']
|
||||||
|
: ['status' => 'error', 'code' => $httpCode, 'response' => $result];
|
||||||
|
}
|
||||||
|
|
||||||
// ── Access Token مع Redis Cache ─────────────────────────
|
// ── Access Token مع Redis Cache ─────────────────────────
|
||||||
private function getAccessToken(): ?string
|
private function getAccessToken(): ?string
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,15 +17,16 @@ class OtpService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── توليد وحفظ OTP ─────────────────────────────────────
|
// ── توليد وحفظ OTP ─────────────────────────────────────
|
||||||
public function generate(string $phone): string
|
// $digits: عدد الأرقام — الافتراضي 6، يمكن تمرير 3 لـ transit (100–999)
|
||||||
|
public function generate(string $phone, int $digits = 3): string
|
||||||
{
|
{
|
||||||
// OTP آمن (6 أرقام عشوائية)
|
$min = (int)str_pad('1', $digits, '0'); // digits=3 → 100 | digits=6 → 100000
|
||||||
$otp = str_pad((string)random_int(100000, 999999), 6, '0', STR_PAD_LEFT);
|
$max = (int)str_pad('9', $digits, '9'); // digits=3 → 999 | digits=6 → 999999
|
||||||
|
$otp = str_pad((string)random_int($min, $max), $digits, '0', STR_PAD_LEFT);
|
||||||
|
|
||||||
if ($this->redis) {
|
if ($this->redis) {
|
||||||
$key = "otp:{$phone}";
|
$key = "otp:{$phone}";
|
||||||
$this->redis->setex($key, self::OTP_TTL, password_hash($otp, PASSWORD_BCRYPT));
|
$this->redis->setex($key, self::OTP_TTL, password_hash($otp, PASSWORD_BCRYPT));
|
||||||
// إعادة تعيين عداد المحاولات
|
|
||||||
$this->redis->del("otp:attempts:{$phone}");
|
$this->redis->del("otp:attempts:{$phone}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -234,3 +234,32 @@ function getInternalSocketKey(): string
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تطبيع رقم الهاتف إلى الصيغة الدولية بدون + (E.164 بدون +)
|
||||||
|
* ناتج ثابت لأي مدخل من JO/SY/EG:
|
||||||
|
* الأردن → 9627XXXXXXXX
|
||||||
|
* سوريا → 9639XXXXXXX
|
||||||
|
* مصر → 20XXXXXXXXXX
|
||||||
|
* يُستخدم دائماً قبل التشفير وقبل الاستعلام.
|
||||||
|
*/
|
||||||
|
function normalizePhone(string $phone): string
|
||||||
|
{
|
||||||
|
$d = preg_replace('/\D+/', '', $phone);
|
||||||
|
|
||||||
|
// سوريا: 09X → 963X | 9X (9 أرقام) → 963X | 963... مكتمل
|
||||||
|
if (strlen($d) === 10 && str_starts_with($d, '09')) return '963' . substr($d, 1);
|
||||||
|
if (strlen($d) === 9 && str_starts_with($d, '9')) return '963' . $d;
|
||||||
|
if (strlen($d) === 12 && str_starts_with($d, '963')) return $d;
|
||||||
|
|
||||||
|
// الأردن: 07X → 962X | 7X (9 أرقام) → 962X | 962... مكتمل
|
||||||
|
if (strlen($d) === 10 && str_starts_with($d, '07')) return '962' . substr($d, 1);
|
||||||
|
if (strlen($d) === 9 && str_starts_with($d, '7')) return '962' . $d;
|
||||||
|
if (strlen($d) === 12 && str_starts_with($d, '962')) return $d;
|
||||||
|
|
||||||
|
// مصر: 01X → 20X | 201... مكتمل
|
||||||
|
if (strlen($d) === 11 && str_starts_with($d, '01')) return '20' . substr($d, 1);
|
||||||
|
if (strlen($d) === 13 && str_starts_with($d, '20')) return $d;
|
||||||
|
|
||||||
|
return $d; // رقم خارج النطاق — يُعاد كما هو
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
// transit/admin/dashboard.php — لوحة اليوم
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../transit/connect_admin.php';
|
||||||
|
|
||||||
|
$today = date('Y-m-d');
|
||||||
|
|
||||||
|
$stTrips = $transit_con->prepare(
|
||||||
|
"SELECT t.id, t.status, t.started_at, t.completed_at, t.delay_minutes, t.current_stop_seq,
|
||||||
|
r.name_ar AS route_name, sc.departure_time,
|
||||||
|
v.plate AS vehicle_plate,
|
||||||
|
d.name AS driver_name, d.phone AS driver_phone
|
||||||
|
FROM transit_trips t
|
||||||
|
JOIN transit_routes r ON r.id = t.route_id
|
||||||
|
JOIN transit_schedules sc ON sc.id = t.schedule_id
|
||||||
|
JOIN transit_vehicles v ON v.id = t.vehicle_id
|
||||||
|
JOIN transit_drivers d ON d.id = t.driver_id
|
||||||
|
WHERE t.org_id = ? AND t.trip_date = ?
|
||||||
|
ORDER BY sc.departure_time ASC"
|
||||||
|
);
|
||||||
|
$stTrips->execute([$transit_org_id, $today]);
|
||||||
|
$trips = $stTrips->fetchAll();
|
||||||
|
|
||||||
|
foreach ($trips as &$trip) {
|
||||||
|
$trip['live_position'] = null;
|
||||||
|
if ($trip['status'] === 'started') {
|
||||||
|
$pos = transitGetBusPosition((int)$trip['id']);
|
||||||
|
if ($pos) $trip['live_position'] = ['lat' => (float)$pos['lat'], 'lng' => (float)$pos['lng'], 'ts' => (int)$pos['ts']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($trip);
|
||||||
|
|
||||||
|
$stStats = $transit_con->prepare(
|
||||||
|
"SELECT COUNT(*) total_trips,
|
||||||
|
SUM(status='started') active_now,
|
||||||
|
SUM(status='completed') completed_today,
|
||||||
|
SUM(status='no_show') no_show_today
|
||||||
|
FROM transit_trips WHERE org_id=? AND trip_date=?"
|
||||||
|
);
|
||||||
|
$stStats->execute([$transit_org_id, $today]);
|
||||||
|
$stats = $stStats->fetch();
|
||||||
|
|
||||||
|
$stMembers = $transit_con->prepare(
|
||||||
|
"SELECT COUNT(*) active_members FROM transit_enrollments WHERE org_id=? AND status='active'"
|
||||||
|
);
|
||||||
|
$stMembers->execute([$transit_org_id]);
|
||||||
|
|
||||||
|
$stBc = $transit_con->prepare(
|
||||||
|
"SELECT id, title_ar, body_ar, sent_at, target_type FROM transit_broadcasts
|
||||||
|
WHERE org_id=? ORDER BY created_at DESC LIMIT 5"
|
||||||
|
);
|
||||||
|
$stBc->execute([$transit_org_id]);
|
||||||
|
|
||||||
|
jsonSuccess([
|
||||||
|
'date' => $today,
|
||||||
|
'trips' => $trips,
|
||||||
|
'stats' => [
|
||||||
|
'total_trips' => (int)$stats['total_trips'],
|
||||||
|
'active_now' => (int)$stats['active_now'],
|
||||||
|
'completed_today' => (int)$stats['completed_today'],
|
||||||
|
'no_show_today' => (int)$stats['no_show_today'],
|
||||||
|
'active_members' => (int)$stMembers->fetchColumn(),
|
||||||
|
],
|
||||||
|
'recent_broadcasts' => $stBc->fetchAll(),
|
||||||
|
]);
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
// transit/admin/login_request.php — الخطوة 1: هاتف المشرف → OTP
|
||||||
|
// POST: phone
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../core/bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../functions.php';
|
||||||
|
|
||||||
|
try { $transit_con = Database::get('transit'); }
|
||||||
|
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
|
||||||
|
|
||||||
|
$rawPhone = filterRequest('phone');
|
||||||
|
if (!$rawPhone) jsonError('Phone is required');
|
||||||
|
$phone = normalizePhone($rawPhone);
|
||||||
|
|
||||||
|
if (transitIsOtpLocked($phone)) {
|
||||||
|
jsonError('Too many attempts. Try again in 30 minutes.', 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
$phoneEnc = $encryptionHelper->encryptData($phone);
|
||||||
|
$st = $transit_con->prepare(
|
||||||
|
"SELECT a.id, o.contract_status
|
||||||
|
FROM transit_org_admins a
|
||||||
|
JOIN transit_orgs o ON o.id = a.org_id
|
||||||
|
WHERE a.phone = ? AND a.is_active = 1 LIMIT 1"
|
||||||
|
);
|
||||||
|
$st->execute([$phoneEnc]);
|
||||||
|
$admin = $st->fetch();
|
||||||
|
|
||||||
|
if (!$admin) jsonError('Invalid credentials', 401);
|
||||||
|
if ($admin['contract_status'] === 'terminated') jsonError('Account suspended', 403);
|
||||||
|
|
||||||
|
transitSendAdminOtp($phone);
|
||||||
|
|
||||||
|
jsonSuccess(null, 'OTP sent successfully');
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
// transit/admin/login_verify.php — الخطوة 2: OTP → session token
|
||||||
|
// POST: phone, otp
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../core/bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../functions.php';
|
||||||
|
|
||||||
|
try { $transit_con = Database::get('transit'); }
|
||||||
|
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
|
||||||
|
|
||||||
|
requireTransitFields(['phone', 'otp']);
|
||||||
|
$phone = normalizePhone(filterRequest('phone'));
|
||||||
|
$otp = preg_replace('/\D/', '', filterRequest('otp'));
|
||||||
|
|
||||||
|
if (!transitVerifyAdminOtp($phone, $otp)) jsonError('Invalid or expired OTP', 401);
|
||||||
|
|
||||||
|
$phoneEnc = $encryptionHelper->encryptData($phone);
|
||||||
|
$st = $transit_con->prepare(
|
||||||
|
"SELECT a.id, a.org_id, a.name, a.role, a.permissions,
|
||||||
|
o.name_ar, o.name_en, o.type, o.contract_status, o.logo_url
|
||||||
|
FROM transit_org_admins a
|
||||||
|
JOIN transit_orgs o ON o.id = a.org_id
|
||||||
|
WHERE a.phone = ? AND a.is_active = 1 LIMIT 1"
|
||||||
|
);
|
||||||
|
$st->execute([$phoneEnc]);
|
||||||
|
$admin = $st->fetch();
|
||||||
|
if (!$admin) jsonError('Admin not found', 401);
|
||||||
|
|
||||||
|
$token = transitCreateSession((int)$admin['id'], (int)$admin['org_id']);
|
||||||
|
|
||||||
|
jsonSuccess([
|
||||||
|
'token' => $token,
|
||||||
|
'expires_in' => 86400,
|
||||||
|
'admin' => [
|
||||||
|
'id' => (int)$admin['id'],
|
||||||
|
'name' => $admin['name'],
|
||||||
|
'role' => $admin['role'],
|
||||||
|
'permissions' => json_decode($admin['permissions'] ?? '{}', true),
|
||||||
|
],
|
||||||
|
'org' => [
|
||||||
|
'id' => (int)$admin['org_id'],
|
||||||
|
'name_ar' => $admin['name_ar'],
|
||||||
|
'name_en' => $admin['name_en'],
|
||||||
|
'type' => $admin['type'],
|
||||||
|
'contract_status' => $admin['contract_status'],
|
||||||
|
'logo_url' => $admin['logo_url'],
|
||||||
|
],
|
||||||
|
], 'Login successful');
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
// transit/broadcast/send.php — المشرف يرسل إعلاناً للطلاب عبر FCM
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../transit/connect_admin.php';
|
||||||
|
|
||||||
|
requireTransitFields(['body_ar']);
|
||||||
|
|
||||||
|
$bodyAr = filterRequest('body_ar');
|
||||||
|
$titleAr = filterRequest('title_ar') ?: 'إعلان من الجامعة';
|
||||||
|
$targetType = filterRequest('target_type') ?: 'all';
|
||||||
|
$targetId = filterRequest('target_id', 'int') ?: null;
|
||||||
|
|
||||||
|
if (!in_array($targetType, ['all', 'route'])) $targetType = 'all';
|
||||||
|
|
||||||
|
$fcmTopic = ($targetType === 'route' && $targetId)
|
||||||
|
? transitRouteTopic($targetId)
|
||||||
|
: transitOrgTopic($transit_org_id);
|
||||||
|
|
||||||
|
$transit_con->prepare(
|
||||||
|
"INSERT INTO transit_broadcasts
|
||||||
|
(org_id, sent_by, target_type, target_id, title_ar, body_ar, fcm_topic, sent_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,NOW())"
|
||||||
|
)->execute([$transit_org_id, $transit_admin_id, $targetType, $targetId, $titleAr, $bodyAr, $fcmTopic]);
|
||||||
|
|
||||||
|
$broadcastId = (int)$transit_con->lastInsertId();
|
||||||
|
|
||||||
|
transitSendTopicNotification(
|
||||||
|
$fcmTopic, $titleAr, $bodyAr,
|
||||||
|
['type' => 'transit_broadcast', 'broadcast_id' => (string)$broadcastId]
|
||||||
|
);
|
||||||
|
|
||||||
|
jsonSuccess(['broadcast_id' => $broadcastId, 'fcm_topic' => $fcmTopic], 'Broadcast sent');
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
// ============================================================
|
||||||
|
// transit/connect_admin.php — بوابة لوحة الويب (مشرف المؤسسة)
|
||||||
|
// المصادقة عبر session token (هاتف + OTP) — مستقلة عن JWT
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../core/bootstrap.php';
|
||||||
|
require_once __DIR__ . '/functions.php';
|
||||||
|
|
||||||
|
// CORS لواجهة الويب
|
||||||
|
$adminOrigins = array_map('trim', explode(',',
|
||||||
|
getenv('TRANSIT_ADMIN_ORIGINS') ?: 'https://transit.siromove.com,https://admin.siromove.com'
|
||||||
|
));
|
||||||
|
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||||
|
if (in_array($origin, $adminOrigins)) {
|
||||||
|
header("Access-Control-Allow-Origin: $origin");
|
||||||
|
header('Access-Control-Allow-Credentials: true');
|
||||||
|
}
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
|
||||||
|
|
||||||
|
// اتصال Transit DB
|
||||||
|
try {
|
||||||
|
$transit_con = Database::get('transit');
|
||||||
|
} catch (Exception $e) {
|
||||||
|
http_response_code(503);
|
||||||
|
echo json_encode(['status' => 'failure', 'message' => 'Transit service unavailable']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// التحقق من الـ session (يُعيد ['admin_id'=>X, 'org_id'=>Y])
|
||||||
|
$transit_admin = transitAuthAdmin();
|
||||||
|
$transit_admin_id = (int)$transit_admin['admin_id'];
|
||||||
|
$transit_org_id = (int)$transit_admin['org_id'];
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
// ============================================================
|
||||||
|
// transit/connect_app.php — بوابة التطبيق (راكب أو سائق باص)
|
||||||
|
// يستخدم JWT الرئيسي نفسه (الراكب مسجّل بالفعل كـ passenger)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../core/bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../functions.php';
|
||||||
|
require_once __DIR__ . '/functions.php';
|
||||||
|
|
||||||
|
// Rate limiting — نفس حد API العادي
|
||||||
|
$limiter = new RateLimiter($redis);
|
||||||
|
$limiter->enforce(RateLimiter::identifier(), 'api');
|
||||||
|
|
||||||
|
// JWT المعتاد — يُستخرج منه passenger_id أو driver_id
|
||||||
|
$jwtService = new JwtService($redis);
|
||||||
|
$decoded = $jwtService->authenticate();
|
||||||
|
|
||||||
|
$transit_user_id = $decoded->user_id ?? null;
|
||||||
|
$transit_user_role = $decoded->role ?? 'passenger'; // 'passenger' أو 'driver'
|
||||||
|
|
||||||
|
// اتصال قاعدة بيانات Transit فقط — ممنوع Database::get('main') في ملفات transit/
|
||||||
|
try {
|
||||||
|
$transit_con = Database::get('transit');
|
||||||
|
} catch (Exception $e) {
|
||||||
|
http_response_code(503);
|
||||||
|
echo json_encode(['status' => 'failure', 'message' => 'Transit service unavailable']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
// transit/driver/activate.php — السائق يفعّل حسابه (بدون JWT — قبل الدخول)
|
||||||
|
// POST: invite_token, phone, otp
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../core/bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../functions.php';
|
||||||
|
require_once __DIR__ . '/../../core/Services/OtpService.php';
|
||||||
|
|
||||||
|
try { $transit_con = Database::get('transit'); }
|
||||||
|
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
|
||||||
|
|
||||||
|
requireTransitFields(['invite_token','phone','otp']);
|
||||||
|
$inviteToken = filterRequest('invite_token');
|
||||||
|
$phone = normalizePhone(filterRequest('phone'));
|
||||||
|
$otp = preg_replace('/\D/', '', filterRequest('otp'));
|
||||||
|
|
||||||
|
if (!transitVerifyAdminOtp($phone, $otp)) jsonError('Invalid or expired OTP', 401);
|
||||||
|
|
||||||
|
$phoneEnc = $encryptionHelper->encryptData($phone);
|
||||||
|
|
||||||
|
$st = $transit_con->prepare(
|
||||||
|
"SELECT id, org_id, name, status FROM transit_drivers
|
||||||
|
WHERE invite_token=? AND phone=? LIMIT 1"
|
||||||
|
);
|
||||||
|
$st->execute([$inviteToken, $phoneEnc]);
|
||||||
|
$driver = $st->fetch();
|
||||||
|
|
||||||
|
if (!$driver) jsonError('Invalid invite token or phone mismatch', 404);
|
||||||
|
if ($driver['status'] === 'active') jsonError('Driver already activated', 409);
|
||||||
|
if ($driver['status'] === 'suspended') jsonError('Account suspended', 403);
|
||||||
|
|
||||||
|
$transit_con->prepare(
|
||||||
|
"UPDATE transit_drivers SET status='active', activated_at=NOW(), invite_token=NULL WHERE id=?"
|
||||||
|
)->execute([$driver['id']]);
|
||||||
|
|
||||||
|
jsonSuccess([
|
||||||
|
'driver_id' => (int)$driver['id'],
|
||||||
|
'org_id' => (int)$driver['org_id'],
|
||||||
|
'name' => $driver['name'],
|
||||||
|
'mode' => 'bus',
|
||||||
|
], 'Driver activated. Welcome to مواصلاتي!');
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
// transit/driver/invite.php — المشرف يضيف سائق جديد
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../transit/connect_admin.php';
|
||||||
|
|
||||||
|
requireTransitFields(['name', 'phone']);
|
||||||
|
$name = filterRequest('name');
|
||||||
|
$phone = normalizePhone(filterRequest('phone'));
|
||||||
|
$license = filterRequest('license_number');
|
||||||
|
|
||||||
|
// تشفير الهاتف مثل باقي أرقام الهواتف
|
||||||
|
$phoneEnc = $encryptionHelper->encryptData($phone);
|
||||||
|
|
||||||
|
$chk = $transit_con->prepare(
|
||||||
|
"SELECT id FROM transit_drivers WHERE phone=? AND org_id=? LIMIT 1"
|
||||||
|
);
|
||||||
|
$chk->execute([$phoneEnc, $transit_org_id]);
|
||||||
|
if ($chk->fetch()) jsonError('Driver phone already registered in this organization', 409);
|
||||||
|
|
||||||
|
$inviteToken = bin2hex(random_bytes(24));
|
||||||
|
$mainDriverId = filterRequest('main_driver_id');
|
||||||
|
|
||||||
|
$transit_con->prepare(
|
||||||
|
"INSERT INTO transit_drivers
|
||||||
|
(org_id,name,phone,license_number,main_driver_id,invite_token,invite_sent_at,status)
|
||||||
|
VALUES (?,?,?,?,?,?,NOW(),'invited')"
|
||||||
|
)->execute([$transit_org_id, $name, $phoneEnc, $license, $mainDriverId, $inviteToken]);
|
||||||
|
|
||||||
|
$driverId = (int)$transit_con->lastInsertId();
|
||||||
|
|
||||||
|
$appLink = getenv('APP_DEEP_LINK_BASE') ?: 'https://siromove.com/driver/transit-activate';
|
||||||
|
$message = "مرحباً {$name}، تمت إضافتك كسائق في منصة مواصلاتي.\nفعّل حسابك: {$appLink}?token={$inviteToken}";
|
||||||
|
sendWhatsAppFromServer($phone, $message);
|
||||||
|
|
||||||
|
jsonSuccess(['driver_id' => $driverId, 'invite_token' => $inviteToken], 'Driver invited successfully');
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
// transit/driver/list.php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../transit/connect_admin.php';
|
||||||
|
|
||||||
|
$status = filterRequest('status') ?: 'active';
|
||||||
|
$allowed = ['all','invited','active','suspended'];
|
||||||
|
if (!in_array($status, $allowed)) $status = 'active';
|
||||||
|
|
||||||
|
$sql = "SELECT id, name, status, activated_at, created_at FROM transit_drivers WHERE org_id=?";
|
||||||
|
$params = [$transit_org_id];
|
||||||
|
if ($status !== 'all') { $sql .= " AND status=?"; $params[] = $status; }
|
||||||
|
$sql .= " ORDER BY name ASC";
|
||||||
|
|
||||||
|
$st = $transit_con->prepare($sql);
|
||||||
|
$st->execute($params);
|
||||||
|
$drivers = $st->fetchAll();
|
||||||
|
|
||||||
|
// فك تشفير هواتف السائقين للعرض
|
||||||
|
foreach ($drivers as &$d) {
|
||||||
|
if (!empty($d['phone'])) {
|
||||||
|
$d['phone'] = $encryptionHelper->decryptData($d['phone']) ?: '***';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($d);
|
||||||
|
|
||||||
|
jsonSuccess(['drivers' => $drivers]);
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
// transit/driver/me.php — السائق يتحقق هل هو سائق باص، ويحصل على معرّفه في مواصلاتي
|
||||||
|
// المصادقة: JWT السائق الرئيسي (نفس main_driver_id)
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../transit/connect_app.php';
|
||||||
|
|
||||||
|
if ($transit_user_role !== 'driver') jsonError('Only drivers can access this endpoint', 403);
|
||||||
|
|
||||||
|
$st = $transit_con->prepare(
|
||||||
|
"SELECT d.id, d.org_id, d.name, d.status,
|
||||||
|
o.name_ar AS org_name, o.type AS org_type
|
||||||
|
FROM transit_drivers d
|
||||||
|
JOIN transit_orgs o ON o.id = d.org_id
|
||||||
|
WHERE d.main_driver_id = ? LIMIT 1"
|
||||||
|
);
|
||||||
|
$st->execute([(string)$transit_user_id]);
|
||||||
|
$driver = $st->fetch();
|
||||||
|
|
||||||
|
if (!$driver) {
|
||||||
|
jsonSuccess(['is_bus_driver' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonSuccess([
|
||||||
|
'is_bus_driver' => true,
|
||||||
|
'driver_transit_id' => (int)$driver['id'],
|
||||||
|
'org_id' => (int)$driver['org_id'],
|
||||||
|
'org_name' => $driver['org_name'],
|
||||||
|
'org_type' => $driver['org_type'],
|
||||||
|
'status' => $driver['status'],
|
||||||
|
]);
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
// transit/enrollment/activate.php — الراكب يفعّل عضويته بالرقم الجامعي
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../transit/connect_app.php';
|
||||||
|
|
||||||
|
if ($transit_user_role !== 'passenger') jsonError('Only passengers can enroll', 403);
|
||||||
|
|
||||||
|
requireTransitFields(['org_id', 'student_id']);
|
||||||
|
|
||||||
|
$orgId = filterRequest('org_id', 'int');
|
||||||
|
$studentId = trim(filterRequest('student_id'));
|
||||||
|
|
||||||
|
$chkOrg = $transit_con->prepare(
|
||||||
|
"SELECT id, name_ar, contract_status FROM transit_orgs WHERE id=? LIMIT 1"
|
||||||
|
);
|
||||||
|
$chkOrg->execute([$orgId]);
|
||||||
|
$org = $chkOrg->fetch();
|
||||||
|
if (!$org) jsonError('Organization not found', 404);
|
||||||
|
if ($org['contract_status'] === 'terminated') jsonError('Organization account inactive', 403);
|
||||||
|
|
||||||
|
$chkEnroll = $transit_con->prepare(
|
||||||
|
"SELECT id, status FROM transit_enrollments WHERE org_id=? AND passenger_id=? LIMIT 1"
|
||||||
|
);
|
||||||
|
$chkEnroll->execute([$orgId, $transit_user_id]);
|
||||||
|
$existing = $chkEnroll->fetch();
|
||||||
|
if ($existing && in_array($existing['status'], ['active', 'pending'])) {
|
||||||
|
jsonError('Enrollment already exists', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
$encryptedId = $encryptionHelper->encryptData($studentId);
|
||||||
|
|
||||||
|
// مطابقة الكشف: هل الرقم موجود في كشف مستورد بدون passenger_id؟
|
||||||
|
$stRoster = $transit_con->prepare(
|
||||||
|
"SELECT id, member_name FROM transit_enrollments
|
||||||
|
WHERE org_id=? AND student_id=? AND passenger_id IS NULL LIMIT 1"
|
||||||
|
);
|
||||||
|
$stRoster->execute([$orgId, $encryptedId]);
|
||||||
|
$rosterRow = $stRoster->fetch();
|
||||||
|
|
||||||
|
if ($rosterRow) {
|
||||||
|
$transit_con->prepare(
|
||||||
|
"UPDATE transit_enrollments
|
||||||
|
SET passenger_id=?, status='active', verified_at=NOW(), verify_method='roster_phone_match'
|
||||||
|
WHERE id=?"
|
||||||
|
)->execute([$transit_user_id, $rosterRow['id']]);
|
||||||
|
|
||||||
|
jsonSuccess([
|
||||||
|
'enrollment_id' => (int)$rosterRow['id'],
|
||||||
|
'org_name' => $org['name_ar'],
|
||||||
|
'status' => 'active',
|
||||||
|
'verify_method' => 'roster_phone_match',
|
||||||
|
], 'تم التحقق من عضويتك بنجاح!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// طلب يدوي ينتظر موافقة المشرف
|
||||||
|
$transit_con->prepare(
|
||||||
|
"INSERT INTO transit_enrollments
|
||||||
|
(org_id, passenger_id, student_id, verify_method, status)
|
||||||
|
VALUES (?,?,?,'manual_admin','pending')"
|
||||||
|
)->execute([$orgId, $transit_user_id, $encryptedId]);
|
||||||
|
|
||||||
|
jsonSuccess([
|
||||||
|
'enrollment_id' => (int)$transit_con->lastInsertId(),
|
||||||
|
'org_name' => $org['name_ar'],
|
||||||
|
'status' => 'pending',
|
||||||
|
], 'طلبك قيد المراجعة. سيتم إشعارك عند التفعيل.');
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
// transit/enrollment/approve.php — المشرف يوافق على طلب عضوية أو يرفضه
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../transit/connect_admin.php';
|
||||||
|
|
||||||
|
requireTransitFields(['enrollment_id', 'action']);
|
||||||
|
|
||||||
|
$enrollId = filterRequest('enrollment_id', 'int');
|
||||||
|
$action = filterRequest('action');
|
||||||
|
$expiresAt = filterRequest('expires_at');
|
||||||
|
|
||||||
|
if (!in_array($action, ['approve', 'reject'])) jsonError('Invalid action. Use: approve or reject', 400);
|
||||||
|
|
||||||
|
$st = $transit_con->prepare(
|
||||||
|
"SELECT id, passenger_id, status FROM transit_enrollments WHERE id=? AND org_id=? LIMIT 1"
|
||||||
|
);
|
||||||
|
$st->execute([$enrollId, $transit_org_id]);
|
||||||
|
$enroll = $st->fetch();
|
||||||
|
|
||||||
|
if (!$enroll) jsonError('Enrollment not found', 404);
|
||||||
|
if ($enroll['status'] !== 'pending') jsonError('Enrollment is not pending', 409);
|
||||||
|
|
||||||
|
$newStatus = ($action === 'approve') ? 'active' : 'suspended';
|
||||||
|
$transit_con->prepare(
|
||||||
|
"UPDATE transit_enrollments
|
||||||
|
SET status=?, verified_at=NOW(), verify_method='manual_admin', expires_at=?
|
||||||
|
WHERE id=?"
|
||||||
|
)->execute([$newStatus, $expiresAt, $enrollId]);
|
||||||
|
|
||||||
|
if ($action === 'approve' && $enroll['passenger_id']) {
|
||||||
|
transitSendTopicNotification(
|
||||||
|
'passenger_' . $enroll['passenger_id'],
|
||||||
|
'تم تفعيل عضويتك',
|
||||||
|
'يمكنك الآن متابعة باصات جامعتك عبر مواصلاتي',
|
||||||
|
['type' => 'transit_enrollment_approved']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonSuccess(['enrollment_id' => $enrollId, 'status' => $newStatus]);
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
// transit/enrollment/import_roster.php — المشرف يرفع كشف الطلاب (CSV)
|
||||||
|
// POST: file (CSV: student_id, name), semester?, notes?
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../transit/connect_admin.php';
|
||||||
|
|
||||||
|
if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
jsonError('No valid file uploaded', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ext = strtolower(pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION));
|
||||||
|
if (!in_array($ext, ['csv', 'txt'])) jsonError('Only CSV files are supported', 400);
|
||||||
|
|
||||||
|
$semester = filterRequest('semester') ?: date('Y') . '-S1';
|
||||||
|
$notes = filterRequest('notes');
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
if (($handle = fopen($_FILES['file']['tmp_name'], 'r')) !== false) {
|
||||||
|
$headers = null;
|
||||||
|
while (($line = fgetcsv($handle, 0, ',')) !== false) {
|
||||||
|
if ($headers === null) { $headers = array_map('strtolower', array_map('trim', $line)); continue; }
|
||||||
|
$row = array_combine($headers, $line);
|
||||||
|
if ($row) $rows[] = $row;
|
||||||
|
}
|
||||||
|
fclose($handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($rows)) jsonError('CSV file is empty or invalid', 400);
|
||||||
|
|
||||||
|
$totalRows = count($rows);
|
||||||
|
$transit_con->prepare(
|
||||||
|
"INSERT INTO transit_rosters (org_id, uploaded_by, filename, total_rows, semester, notes)
|
||||||
|
VALUES (?,?,?,?,?,?)"
|
||||||
|
)->execute([$transit_org_id, $transit_admin_id, $_FILES['file']['name'], $totalRows, $semester, $notes]);
|
||||||
|
$rosterId = (int)$transit_con->lastInsertId();
|
||||||
|
|
||||||
|
$insertSt = $transit_con->prepare(
|
||||||
|
"INSERT IGNORE INTO transit_enrollments
|
||||||
|
(org_id, passenger_id, student_id, member_name, verify_method, status, roster_id)
|
||||||
|
VALUES (?,NULL,?,?,'roster_manual','pending',?)"
|
||||||
|
);
|
||||||
|
|
||||||
|
$matchedRows = 0;
|
||||||
|
$newEnroll = 0;
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$sid = trim($row['student_id'] ?? $row['id'] ?? '');
|
||||||
|
$name = trim($row['name'] ?? $row['full_name'] ?? '');
|
||||||
|
if (!$sid) continue;
|
||||||
|
|
||||||
|
$encSid = $encryptionHelper->encryptData($sid);
|
||||||
|
|
||||||
|
$chk = $transit_con->prepare(
|
||||||
|
"SELECT id, passenger_id FROM transit_enrollments WHERE org_id=? AND student_id=? LIMIT 1"
|
||||||
|
);
|
||||||
|
$chk->execute([$transit_org_id, $encSid]);
|
||||||
|
$existing = $chk->fetch();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
$transit_con->prepare(
|
||||||
|
"UPDATE transit_enrollments SET member_name=?, roster_id=? WHERE id=?"
|
||||||
|
)->execute([$name, $rosterId, $existing['id']]);
|
||||||
|
if ($existing['passenger_id']) $matchedRows++;
|
||||||
|
} else {
|
||||||
|
$insertSt->execute([$transit_org_id, $encSid, $name, $rosterId]);
|
||||||
|
$newEnroll++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$transit_con->prepare(
|
||||||
|
"UPDATE transit_rosters SET matched_rows=?, new_enrollments=? WHERE id=?"
|
||||||
|
)->execute([$matchedRows, $newEnroll, $rosterId]);
|
||||||
|
|
||||||
|
jsonSuccess([
|
||||||
|
'roster_id' => $rosterId,
|
||||||
|
'total_rows' => $totalRows,
|
||||||
|
'new_enrollments' => $newEnroll,
|
||||||
|
'matched' => $matchedRows,
|
||||||
|
], 'Roster imported successfully');
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
// transit/enrollment/list.php — قائمة عضويات المؤسسة (للمشرف)
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../transit/connect_admin.php';
|
||||||
|
|
||||||
|
$status = filterRequest('status') ?: 'all';
|
||||||
|
$page = max(1, (int)(filterRequest('page', 'int') ?? 1));
|
||||||
|
$perPage = min(100, max(10, (int)(filterRequest('per_page', 'int') ?? 50)));
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
|
||||||
|
$allowed = ['all', 'pending', 'active', 'suspended', 'expired'];
|
||||||
|
if (!in_array($status, $allowed)) $status = 'all';
|
||||||
|
|
||||||
|
$where = "org_id = ?";
|
||||||
|
$params = [$transit_org_id];
|
||||||
|
if ($status !== 'all') { $where .= " AND status = ?"; $params[] = $status; }
|
||||||
|
|
||||||
|
$countSt = $transit_con->prepare("SELECT COUNT(*) FROM transit_enrollments WHERE $where");
|
||||||
|
$countSt->execute($params);
|
||||||
|
$total = (int)$countSt->fetchColumn();
|
||||||
|
|
||||||
|
$params[] = $perPage;
|
||||||
|
$params[] = $offset;
|
||||||
|
$st = $transit_con->prepare(
|
||||||
|
"SELECT id, passenger_id, member_name, status, verify_method,
|
||||||
|
verified_at, expires_at, created_at
|
||||||
|
FROM transit_enrollments
|
||||||
|
WHERE $where ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
||||||
|
);
|
||||||
|
$st->execute($params);
|
||||||
|
$enrollments = $st->fetchAll();
|
||||||
|
|
||||||
|
jsonSuccess([
|
||||||
|
'enrollments' => $enrollments,
|
||||||
|
'pagination' => ['total' => $total, 'page' => $page, 'per_page' => $perPage],
|
||||||
|
]);
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
// transit/enrollment/my_enrollments.php — الراكب يستعرض عضوياته في كل المؤسسات
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../transit/connect_app.php';
|
||||||
|
|
||||||
|
if ($transit_user_role !== 'passenger') jsonError('Only passengers can view enrollments', 403);
|
||||||
|
|
||||||
|
$st = $transit_con->prepare(
|
||||||
|
"SELECT e.id, e.org_id, e.status, e.verify_method, e.expires_at, e.created_at,
|
||||||
|
o.name_ar AS org_name, o.name_en AS org_name_en, o.type AS org_type, o.logo_url
|
||||||
|
FROM transit_enrollments e
|
||||||
|
JOIN transit_orgs o ON o.id = e.org_id
|
||||||
|
WHERE e.passenger_id = ?
|
||||||
|
ORDER BY e.created_at DESC"
|
||||||
|
);
|
||||||
|
$st->execute([$transit_user_id]);
|
||||||
|
|
||||||
|
jsonSuccess(['enrollments' => $st->fetchAll()]);
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
<?php
|
||||||
|
// ============================================================
|
||||||
|
// transit/functions.php — دوال خاصة بمنصة مواصلاتي فقط
|
||||||
|
//
|
||||||
|
// ما لا يوجد هنا (يُستخدم مباشرة من النظام الأصلي بعد bootstrap):
|
||||||
|
// • filterRequest() ← core/helpers.php
|
||||||
|
// • jsonSuccess() / jsonError() ← core/helpers.php
|
||||||
|
// • appLog() / securityLog() ← core/helpers.php
|
||||||
|
// • normalizePhone($phone) ← core/helpers.php — يطبّع الهاتف دائماً قبل التشفير أو الاستعلام
|
||||||
|
// • OtpService($redis) ← core/Services/OtpService.php
|
||||||
|
// • $encryptionHelper ← مهيَّأ في bootstrap.php
|
||||||
|
// • $redis ← مهيَّأ في bootstrap.php
|
||||||
|
// • sendWhatsAppFromServer() ← backend/functions.php
|
||||||
|
// • FcmService($redis) ← core/Services/FcmService.php
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// ── حقول مطلوبة (غلاف رفيع يستخدم filterRequest + jsonError) ──
|
||||||
|
function requireTransitFields(array $fields): void
|
||||||
|
{
|
||||||
|
foreach ($fields as $f) {
|
||||||
|
if (filterRequest($f) === null) {
|
||||||
|
jsonError("Missing required field: $f", 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Session مشرف الويب (OTP-based — مستقلة عن JWT) ──────────
|
||||||
|
|
||||||
|
function transitCreateSession(int $adminId, int $orgId): string
|
||||||
|
{
|
||||||
|
global $redis;
|
||||||
|
|
||||||
|
$token = bin2hex(random_bytes(32)); // 64 hex chars
|
||||||
|
$hash = hash('sha256', $token);
|
||||||
|
$expiresAt = date('Y-m-d H:i:s', time() + 86400);
|
||||||
|
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||||
|
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
||||||
|
|
||||||
|
$con = Database::get('transit');
|
||||||
|
$con->prepare(
|
||||||
|
"INSERT INTO transit_sessions (admin_id, org_id, token_hash, ip, user_agent, expires_at)
|
||||||
|
VALUES (?,?,?,?,?,?)"
|
||||||
|
)->execute([$adminId, $orgId, $hash, $ip, $ua, $expiresAt]);
|
||||||
|
|
||||||
|
if ($redis) {
|
||||||
|
$redis->setEx(
|
||||||
|
"transit:session:{$hash}",
|
||||||
|
86400,
|
||||||
|
json_encode(['admin_id' => $adminId, 'org_id' => $orgId])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transitAuthAdmin(): array
|
||||||
|
{
|
||||||
|
global $redis;
|
||||||
|
|
||||||
|
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['HTTP_X_TRANSIT_TOKEN'] ?? '';
|
||||||
|
$token = str_replace('Bearer ', '', $header);
|
||||||
|
if (!$token) jsonError('Missing admin session token', 401);
|
||||||
|
|
||||||
|
$hash = hash('sha256', $token);
|
||||||
|
|
||||||
|
if ($redis) {
|
||||||
|
$val = $redis->get("transit:session:{$hash}");
|
||||||
|
if ($val) {
|
||||||
|
$data = json_decode($val, true);
|
||||||
|
if ($data) return $data;
|
||||||
|
}
|
||||||
|
jsonError('Session expired or invalid', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback MySQL
|
||||||
|
$con = Database::get('transit');
|
||||||
|
$st = $con->prepare(
|
||||||
|
"SELECT admin_id, org_id FROM transit_sessions
|
||||||
|
WHERE token_hash=? AND expires_at > NOW() LIMIT 1"
|
||||||
|
);
|
||||||
|
$st->execute([$hash]);
|
||||||
|
$row = $st->fetch();
|
||||||
|
if (!$row) jsonError('Session expired or invalid', 401);
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── OTP مشرف الويب (يستخدم OtpService الموجود) ─────────────
|
||||||
|
|
||||||
|
function transitSendAdminOtp(string $phone): void
|
||||||
|
{
|
||||||
|
global $redis;
|
||||||
|
|
||||||
|
$otpSvc = new OtpService($redis);
|
||||||
|
$otp = $otpSvc->generate($phone, 3); // 3 أرقام فقط (100–999) حسب سياسة مواصلاتي
|
||||||
|
|
||||||
|
$message = "رمز تسجيل الدخول لمنصة مواصلاتي: {$otp}\nصالح لمدة 5 دقائق.";
|
||||||
|
sendWhatsAppFromServer($phone, $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function transitVerifyAdminOtp(string $phone, string $otp): bool
|
||||||
|
{
|
||||||
|
global $redis;
|
||||||
|
$otpSvc = new OtpService($redis);
|
||||||
|
return $otpSvc->verify($phone, $otp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function transitIsOtpLocked(string $phone): bool
|
||||||
|
{
|
||||||
|
global $redis;
|
||||||
|
$otpSvc = new OtpService($redis);
|
||||||
|
return $otpSvc->isLocked($phone);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── تشفير الرقم الجامعي / الوظيفي (يستخدم EncryptionHelper) ─
|
||||||
|
|
||||||
|
function transitEncryptStudentId(string $raw): string
|
||||||
|
{
|
||||||
|
global $encryptionHelper;
|
||||||
|
return $encryptionHelper->encryptData($raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function transitDecryptStudentId(string $enc): string
|
||||||
|
{
|
||||||
|
global $encryptionHelper;
|
||||||
|
return (string)$encryptionHelper->decryptData($enc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── إشعارات FCM Topic للخطوط ────────────────────────────────
|
||||||
|
|
||||||
|
function transitRouteTopic(int $routeId): string
|
||||||
|
{
|
||||||
|
return 'transit_route_' . $routeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transitOrgTopic(int $orgId): string
|
||||||
|
{
|
||||||
|
return 'transit_org_' . $orgId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transitSendTopicNotification(string $topic, string $title, string $body, array $data = []): void
|
||||||
|
{
|
||||||
|
global $redis;
|
||||||
|
|
||||||
|
$fcm = new FcmService($redis);
|
||||||
|
$result = $fcm->sendToTopic($topic, $title, $body, $data);
|
||||||
|
|
||||||
|
if ($result['status'] !== 'success') {
|
||||||
|
appLog("[TRANSIT][FCM] Topic push failed on '{$topic}': " . json_encode($result), 'WARNING');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── موقع الباص في Redis سيرفر الموقع ($redisLocation) ────────
|
||||||
|
// موقع الباص يكتبه driver_socket على Redis سيرفر الموقع (بدون بادئة siro:)
|
||||||
|
// تماماً مثل موقع السائق العادي — لذلك نقرؤه/نكتبه عبر $redisLocation
|
||||||
|
// وليس $redis الرئيسي (الذي يضيف بادئة siro: ولا يراه السوكت).
|
||||||
|
|
||||||
|
function transitUpdateBusPosition(int $tripId, float $lat, float $lng): void
|
||||||
|
{
|
||||||
|
global $redisLocation;
|
||||||
|
if (!$redisLocation) return;
|
||||||
|
|
||||||
|
$redisLocation->hMSet("transit:trip:{$tripId}:pos", ['lat' => $lat, 'lng' => $lng, 'ts' => time()]);
|
||||||
|
$redisLocation->expire("transit:trip:{$tripId}:pos", 86400);
|
||||||
|
}
|
||||||
|
|
||||||
|
function transitGetBusPosition(int $tripId): ?array
|
||||||
|
{
|
||||||
|
global $redisLocation;
|
||||||
|
if (!$redisLocation) return null;
|
||||||
|
|
||||||
|
$pos = $redisLocation->hGetAll("transit:trip:{$tripId}:pos");
|
||||||
|
return ($pos && isset($pos['lat'])) ? $pos : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ملكية الرحلة (Trip Ownership) ─────────────────────────────
|
||||||
|
// تُكتب عند بدء الرحلة على Redis سيرفر الموقع، ليتحقق منها driver_socket
|
||||||
|
// قبل قبول أي update_bus_location — يمنع أي سائق آخر من انتحال trip_id
|
||||||
|
// أو البث لخط لا يملكه. main_driver_id هو نفسه sub في JWT (الحساب في
|
||||||
|
// جدول driver الرئيسي)، وليس transit_drivers.id.
|
||||||
|
|
||||||
|
function transitSetTripOwner(int $tripId, string $mainDriverId, int $routeId): void
|
||||||
|
{
|
||||||
|
global $redisLocation;
|
||||||
|
if (!$redisLocation || !$mainDriverId) return;
|
||||||
|
|
||||||
|
$key = "transit:trip:{$tripId}:owner";
|
||||||
|
$redisLocation->hMSet($key, ['driver_id' => $mainDriverId, 'route_id' => $routeId]);
|
||||||
|
$redisLocation->expire($key, 86400);
|
||||||
|
}
|
||||||
|
|
||||||
|
function transitClearTripOwner(int $tripId): void
|
||||||
|
{
|
||||||
|
global $redisLocation;
|
||||||
|
if (!$redisLocation) return;
|
||||||
|
|
||||||
|
$redisLocation->del("transit:trip:{$tripId}:owner");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── days_mask helpers ────────────────────────────────────────
|
||||||
|
// bit0=Sun … bit6=Sat | 62 = 0b0111110 = الأحد–الخميس
|
||||||
|
|
||||||
|
function transitDayActive(int $mask): bool
|
||||||
|
{
|
||||||
|
return (bool)(($mask >> (int)date('w')) & 1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
// transit/notification/subscribe.php — الراكب يشترك في إشعارات خط (FCM Topic)
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../transit/connect_app.php';
|
||||||
|
|
||||||
|
requireTransitFields(['route_id', 'fcm_token']);
|
||||||
|
|
||||||
|
$routeId = filterRequest('route_id', 'int');
|
||||||
|
$fcmToken = filterRequest('fcm_token');
|
||||||
|
$stopId = filterRequest('preferred_stop_id', 'int') ?: null;
|
||||||
|
|
||||||
|
// تحقق العضوية النشطة في المؤسسة المالكة للخط
|
||||||
|
$chk = $transit_con->prepare(
|
||||||
|
"SELECT e.id
|
||||||
|
FROM transit_enrollments e
|
||||||
|
JOIN transit_routes r ON r.org_id = e.org_id
|
||||||
|
WHERE r.id=? AND e.passenger_id=? AND e.status='active' LIMIT 1"
|
||||||
|
);
|
||||||
|
$chk->execute([$routeId, $transit_user_id]);
|
||||||
|
if (!$chk->fetch()) jsonError('Active enrollment required to subscribe', 403);
|
||||||
|
|
||||||
|
if ($stopId) {
|
||||||
|
$transit_con->prepare(
|
||||||
|
"UPDATE transit_enrollments
|
||||||
|
SET preferred_stop_id=?, preferred_route_id=?
|
||||||
|
WHERE passenger_id=? AND org_id=(SELECT org_id FROM transit_routes WHERE id=? LIMIT 1)"
|
||||||
|
)->execute([$stopId, $routeId, $transit_user_id, $routeId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$topic = transitRouteTopic($routeId);
|
||||||
|
|
||||||
|
// اشتراك FCM Topic يتم من التطبيق مباشرة (firebaseMessaging.subscribeToTopic)
|
||||||
|
// هنا نسجّل فقط المحطة المفضلة ونؤكد الموضوع
|
||||||
|
appLog("[TRANSIT][NOTIFY] passenger {$transit_user_id} subscribed to {$topic}");
|
||||||
|
|
||||||
|
jsonSuccess([
|
||||||
|
'topic' => $topic,
|
||||||
|
'preferred_stop_id' => $stopId,
|
||||||
|
], 'Subscribed to route notifications');
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
// transit/org/browse.php — الراكب يتصفح المؤسسات المتاحة للتفعيل
|
||||||
|
// POST: country?, type?, search?
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../transit/connect_app.php';
|
||||||
|
|
||||||
|
$country = filterRequest('country');
|
||||||
|
$type = filterRequest('type');
|
||||||
|
$search = filterRequest('search');
|
||||||
|
|
||||||
|
$where = "contract_status IN ('trial','active')";
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($country) { $where .= ' AND country = ?'; $params[] = strtoupper(substr($country, 0, 2)); }
|
||||||
|
if ($type) { $where .= ' AND type = ?'; $params[] = $type; }
|
||||||
|
if ($search) { $where .= ' AND (name_ar LIKE ? OR name_en LIKE ?)'; $params[] = "%$search%"; $params[] = "%$search%"; }
|
||||||
|
|
||||||
|
$st = $transit_con->prepare(
|
||||||
|
"SELECT id, type, country, city, name_ar, name_en, logo_url
|
||||||
|
FROM transit_orgs WHERE $where ORDER BY name_ar ASC LIMIT 200"
|
||||||
|
);
|
||||||
|
$st->execute($params);
|
||||||
|
|
||||||
|
jsonSuccess(['orgs' => $st->fetchAll()]);
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
// transit/org/register.php — تسجيل مؤسسة جديدة (داخلي بـ X-Internal-Key)
|
||||||
|
// POST: type, country, city, name_ar, name_en, admin_name, admin_phone, ...
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../core/bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../functions.php';
|
||||||
|
|
||||||
|
$internalKey = getenv('SOCKET_INTERNAL_KEY') ?: '';
|
||||||
|
$requestKey = $_SERVER['HTTP_X_INTERNAL_KEY'] ?? filterRequest('internal_key') ?? '';
|
||||||
|
if (!$internalKey || !hash_equals($internalKey, $requestKey)) jsonError('Forbidden', 403);
|
||||||
|
|
||||||
|
try { $transit_con = Database::get('transit'); }
|
||||||
|
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
|
||||||
|
|
||||||
|
requireTransitFields(['type','country','city','name_ar','name_en','admin_name','admin_phone']);
|
||||||
|
|
||||||
|
$type = filterRequest('type');
|
||||||
|
$country = strtoupper(substr(filterRequest('country'), 0, 2));
|
||||||
|
$city = filterRequest('city');
|
||||||
|
$nameAr = filterRequest('name_ar');
|
||||||
|
$nameEn = filterRequest('name_en');
|
||||||
|
$adminName = filterRequest('admin_name');
|
||||||
|
$adminPhone = normalizePhone(filterRequest('admin_phone'));
|
||||||
|
$adminRole = filterRequest('admin_role') ?: 'owner';
|
||||||
|
$trialEndsAt = filterRequest('trial_ends_at') ?: date('Y-m-d', strtotime('+90 days'));
|
||||||
|
|
||||||
|
$allowedTypes = ['university','school','hotel','company','transporter'];
|
||||||
|
if (!in_array($type, $allowedTypes)) jsonError('Invalid type. Allowed: ' . implode(', ', $allowedTypes));
|
||||||
|
|
||||||
|
$chk = $transit_con->prepare("SELECT id FROM transit_orgs WHERE name_ar=? AND country=? LIMIT 1");
|
||||||
|
$chk->execute([$nameAr, $country]);
|
||||||
|
if ($chk->fetch()) jsonError('Organization already exists', 409);
|
||||||
|
|
||||||
|
$adminPhoneEnc = $encryptionHelper->encryptData($adminPhone);
|
||||||
|
$contactPhoneRaw = normalizePhone(filterRequest('contact_phone') ?? '');
|
||||||
|
$contactPhoneEnc = $contactPhoneRaw ? $encryptionHelper->encryptData($contactPhoneRaw) : null;
|
||||||
|
|
||||||
|
$transit_con->beginTransaction();
|
||||||
|
try {
|
||||||
|
$transit_con->prepare(
|
||||||
|
"INSERT INTO transit_orgs (type,country,city,name_ar,name_en,contact_phone,contact_email,website,contract_status,trial_ends_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,'trial',?)"
|
||||||
|
)->execute([$type,$country,$city,$nameAr,$nameEn,$contactPhoneEnc,
|
||||||
|
filterRequest('contact_email'),filterRequest('website'),$trialEndsAt]);
|
||||||
|
|
||||||
|
$orgId = (int)$transit_con->lastInsertId();
|
||||||
|
|
||||||
|
$transit_con->prepare(
|
||||||
|
"INSERT INTO transit_org_admins (org_id, name, phone, role) VALUES (?,?,?,?)"
|
||||||
|
)->execute([$orgId, $adminName, $adminPhoneEnc, $adminRole]);
|
||||||
|
|
||||||
|
$transit_con->commit();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$transit_con->rollBack();
|
||||||
|
appLog('[TRANSIT][ORG][register] ' . $e->getMessage(), 'ERROR');
|
||||||
|
jsonError('Failed to create organization', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonSuccess(['org_id' => $orgId, 'name_ar' => $nameAr, 'type' => $type, 'country' => $country], 'Organization registered successfully');
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
// transit/route/add.php — إضافة خط جديد (مسودة)
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../transit/connect_admin.php';
|
||||||
|
|
||||||
|
$nameAr = filterRequest('name_ar');
|
||||||
|
if (!$nameAr) jsonError('name_ar is required');
|
||||||
|
|
||||||
|
$direction = filterRequest('direction') ?: 'outbound';
|
||||||
|
if (!in_array($direction, ['outbound','inbound','circular'])) $direction = 'outbound';
|
||||||
|
|
||||||
|
$stops = json_decode(filterRequest('stops') ?? '[]', true) ?: [];
|
||||||
|
|
||||||
|
$transit_con->beginTransaction();
|
||||||
|
try {
|
||||||
|
$transit_con->prepare(
|
||||||
|
"INSERT INTO transit_routes
|
||||||
|
(org_id,name_ar,name_en,direction,polyline,distance_km,duration_min,status,created_by)
|
||||||
|
VALUES (?,?,?,?,?,?,?,'draft',?)"
|
||||||
|
)->execute([
|
||||||
|
$transit_org_id, $nameAr,
|
||||||
|
filterRequest('name_en'),
|
||||||
|
$direction,
|
||||||
|
filterRequest('polyline'),
|
||||||
|
filterRequest('distance_km','float'),
|
||||||
|
filterRequest('duration_min','int'),
|
||||||
|
$transit_admin_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$routeId = (int)$transit_con->lastInsertId();
|
||||||
|
|
||||||
|
$insertStop = $transit_con->prepare(
|
||||||
|
"INSERT INTO transit_stops
|
||||||
|
(route_id,org_id,sequence,name_ar,name_en,latitude,longitude,geofence_radius,eta_offset_min,is_major)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?)"
|
||||||
|
);
|
||||||
|
foreach ($stops as $i => $s) {
|
||||||
|
if (empty($s['lat']) || empty($s['lng']) || empty($s['name_ar'])) continue;
|
||||||
|
$insertStop->execute([
|
||||||
|
$routeId, $transit_org_id, $i + 1,
|
||||||
|
$s['name_ar'], $s['name_en'] ?? null,
|
||||||
|
(float)$s['lat'], (float)$s['lng'],
|
||||||
|
(int)($s['radius'] ?? 150),
|
||||||
|
isset($s['eta_offset_min']) ? (int)$s['eta_offset_min'] : null,
|
||||||
|
(int)($s['is_major'] ?? 0),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$transit_con->commit();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$transit_con->rollBack();
|
||||||
|
appLog('[TRANSIT][ROUTE] ' . $e->getMessage(), 'ERROR');
|
||||||
|
jsonError('Failed to save route', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonSuccess(['route_id' => $routeId, 'stops_added' => count($stops)], 'Route saved as draft');
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
// transit/route/for_org.php — الراكب يستعرض خطوط مؤسسته (يشترط عضوية نشطة)
|
||||||
|
// POST: org_id
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../transit/connect_app.php';
|
||||||
|
|
||||||
|
if ($transit_user_role !== 'passenger') jsonError('Only passengers can browse routes', 403);
|
||||||
|
|
||||||
|
$orgId = filterRequest('org_id', 'int');
|
||||||
|
if (!$orgId) jsonError('org_id is required', 400);
|
||||||
|
|
||||||
|
$chk = $transit_con->prepare(
|
||||||
|
"SELECT id FROM transit_enrollments WHERE org_id=? AND passenger_id=? AND status='active' LIMIT 1"
|
||||||
|
);
|
||||||
|
$chk->execute([$orgId, $transit_user_id]);
|
||||||
|
if (!$chk->fetch()) jsonError('Active enrollment required to view routes', 403);
|
||||||
|
|
||||||
|
$st = $transit_con->prepare(
|
||||||
|
"SELECT id, name_ar, name_en, direction, distance_km, duration_min, status
|
||||||
|
FROM transit_routes WHERE org_id=? AND status='active' ORDER BY name_ar ASC"
|
||||||
|
);
|
||||||
|
$st->execute([$orgId]);
|
||||||
|
$routes = $st->fetchAll();
|
||||||
|
|
||||||
|
if ($routes) {
|
||||||
|
$ids = implode(',', array_map('intval', array_column($routes, 'id')));
|
||||||
|
$counts = $transit_con->query(
|
||||||
|
"SELECT route_id, COUNT(*) c FROM transit_stops WHERE route_id IN ($ids) GROUP BY route_id"
|
||||||
|
)->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||||
|
foreach ($routes as &$r) $r['stop_count'] = (int)($counts[$r['id']] ?? 0);
|
||||||
|
unset($r);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonSuccess(['routes' => $routes]);
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
// transit/route/get.php — تفاصيل خط + محطاته + جداوله
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../transit/connect_admin.php';
|
||||||
|
|
||||||
|
$routeId = filterRequest('route_id', 'int');
|
||||||
|
if (!$routeId) jsonError('route_id is required');
|
||||||
|
|
||||||
|
$st = $transit_con->prepare("SELECT * FROM transit_routes WHERE id=? AND org_id=? LIMIT 1");
|
||||||
|
$st->execute([$routeId, $transit_org_id]);
|
||||||
|
$route = $st->fetch();
|
||||||
|
if (!$route) jsonError('Route not found', 404);
|
||||||
|
|
||||||
|
$stStops = $transit_con->prepare(
|
||||||
|
"SELECT id,sequence,name_ar,name_en,latitude,longitude,geofence_radius,eta_offset_min,is_major
|
||||||
|
FROM transit_stops WHERE route_id=? ORDER BY sequence ASC"
|
||||||
|
);
|
||||||
|
$stStops->execute([$routeId]);
|
||||||
|
$route['stops'] = $stStops->fetchAll();
|
||||||
|
|
||||||
|
$stSch = $transit_con->prepare(
|
||||||
|
"SELECT s.id, s.departure_time, s.days_mask, s.valid_from, s.valid_until, s.is_active,
|
||||||
|
d.name AS driver_name, v.plate AS vehicle_plate
|
||||||
|
FROM transit_schedules s
|
||||||
|
LEFT JOIN transit_drivers d ON d.id=s.driver_id
|
||||||
|
LEFT JOIN transit_vehicles v ON v.id=s.vehicle_id
|
||||||
|
WHERE s.route_id=? AND s.org_id=? ORDER BY s.departure_time ASC"
|
||||||
|
);
|
||||||
|
$stSch->execute([$routeId, $transit_org_id]);
|
||||||
|
$route['schedules'] = $stSch->fetchAll();
|
||||||
|
|
||||||
|
jsonSuccess(['route' => $route]);
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
// transit/route/list.php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../transit/connect_admin.php';
|
||||||
|
|
||||||
|
$status = filterRequest('status') ?: 'active';
|
||||||
|
if (!in_array($status, ['all','draft','active','suspended'])) $status = 'active';
|
||||||
|
|
||||||
|
$sql = "SELECT id, name_ar, name_en, direction, distance_km, duration_min, status, created_at FROM transit_routes WHERE org_id=?";
|
||||||
|
$params = [$transit_org_id];
|
||||||
|
if ($status !== 'all') { $sql .= " AND status=?"; $params[] = $status; }
|
||||||
|
$sql .= " ORDER BY name_ar ASC";
|
||||||
|
|
||||||
|
$st = $transit_con->prepare($sql);
|
||||||
|
$st->execute($params);
|
||||||
|
$routes = $st->fetchAll();
|
||||||
|
|
||||||
|
if ($routes) {
|
||||||
|
$ids = implode(',', array_map('intval', array_column($routes, 'id')));
|
||||||
|
$cntSt = $transit_con->query(
|
||||||
|
"SELECT route_id, COUNT(*) cnt FROM transit_stops WHERE route_id IN ($ids) GROUP BY route_id"
|
||||||
|
);
|
||||||
|
$counts = $cntSt ? array_column($cntSt->fetchAll(), 'cnt', 'route_id') : [];
|
||||||
|
foreach ($routes as &$r) $r['stop_count'] = (int)($counts[$r['id']] ?? 0);
|
||||||
|
unset($r);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonSuccess(['routes' => $routes]);
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
// transit/schedule/add.php — ربط جدول زمني بخط + سائق + مركبة
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../transit/connect_admin.php';
|
||||||
|
|
||||||
|
requireTransitFields(['route_id', 'departure_time', 'days_mask']);
|
||||||
|
|
||||||
|
$routeId = filterRequest('route_id', 'int');
|
||||||
|
$departureTime = filterRequest('departure_time');
|
||||||
|
$daysMask = filterRequest('days_mask', 'int') ?? 62;
|
||||||
|
$driverId = filterRequest('driver_id', 'int') ?: null;
|
||||||
|
$vehicleId = filterRequest('vehicle_id', 'int') ?: null;
|
||||||
|
$validFrom = filterRequest('valid_from') ?: date('Y-m-d');
|
||||||
|
$validUntil = filterRequest('valid_until');
|
||||||
|
|
||||||
|
$chk = $transit_con->prepare("SELECT id FROM transit_routes WHERE id=? AND org_id=? LIMIT 1");
|
||||||
|
$chk->execute([$routeId, $transit_org_id]);
|
||||||
|
if (!$chk->fetch()) jsonError('Route not found or access denied', 404);
|
||||||
|
|
||||||
|
if ($vehicleId) {
|
||||||
|
$chkV = $transit_con->prepare("SELECT id FROM transit_vehicles WHERE id=? AND org_id=? LIMIT 1");
|
||||||
|
$chkV->execute([$vehicleId, $transit_org_id]);
|
||||||
|
if (!$chkV->fetch()) jsonError('Vehicle not found or access denied', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($driverId) {
|
||||||
|
$chkD = $transit_con->prepare("SELECT id FROM transit_drivers WHERE id=? AND org_id=? AND status='active' LIMIT 1");
|
||||||
|
$chkD->execute([$driverId, $transit_org_id]);
|
||||||
|
if (!$chkD->fetch()) jsonError('Driver not found or not active', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$transit_con->prepare(
|
||||||
|
"INSERT INTO transit_schedules
|
||||||
|
(route_id, org_id, vehicle_id, driver_id, days_mask, departure_time, valid_from, valid_until, created_by)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?)"
|
||||||
|
)->execute([$routeId, $transit_org_id, $vehicleId, $driverId, $daysMask, $departureTime, $validFrom, $validUntil, $transit_admin_id]);
|
||||||
|
|
||||||
|
jsonSuccess(['schedule_id' => (int)$transit_con->lastInsertId()], 'Schedule added');
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- schema_transit.sql — قاعدة بيانات منصة مواصلاتي (siroTransitDb)
|
||||||
|
-- عزل كامل عن main/ride/tracking — ممنوع أي JOIN خارجي
|
||||||
|
-- الربط بالنظام الرئيسي عبر passenger_id / driver_id فقط
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
SET FOREIGN_KEY_CHECKS = 0;
|
||||||
|
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
|
||||||
|
SET time_zone = "+00:00";
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------
|
||||||
|
-- 1. transit_orgs — المؤسسات (جامعات، مدارس، فنادق، شركات، ناقلون)
|
||||||
|
-- -----------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `transit_orgs` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`type` ENUM('university','school','hotel','company','transporter') NOT NULL,
|
||||||
|
`country` CHAR(2) NOT NULL COMMENT 'JO | SY | EG | ...',
|
||||||
|
`city` VARCHAR(80) NOT NULL,
|
||||||
|
`name_ar` VARCHAR(200) NOT NULL,
|
||||||
|
`name_en` VARCHAR(200) NOT NULL,
|
||||||
|
`logo_url` VARCHAR(500) DEFAULT NULL,
|
||||||
|
`campus_polygon` JSON DEFAULT NULL COMMENT 'حدود الحرم — مصفوفة [{lat,lng}]',
|
||||||
|
`website` VARCHAR(300) DEFAULT NULL,
|
||||||
|
`contact_phone` VARCHAR(30) DEFAULT NULL,
|
||||||
|
`contact_email` VARCHAR(150) DEFAULT NULL,
|
||||||
|
`contract_status` ENUM('trial','active','suspended','terminated') NOT NULL DEFAULT 'trial',
|
||||||
|
`trial_ends_at` DATE DEFAULT NULL,
|
||||||
|
`notes` TEXT DEFAULT NULL,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_country_type` (`country`, `type`),
|
||||||
|
KEY `idx_contract_status` (`contract_status`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------
|
||||||
|
-- 2. transit_org_links — ربط ناقل ↔ مؤسسة (النمط الثاني)
|
||||||
|
-- جامعة حكومية لا تملك أسطولاً → تُخدَم بناقل مسجّل
|
||||||
|
-- -----------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `transit_org_links` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`institution_id` INT UNSIGNED NOT NULL COMMENT 'org_id للمؤسسة (جامعة/مدرسة)',
|
||||||
|
`transporter_id` INT UNSIGNED NOT NULL COMMENT 'org_id للناقل (type=transporter)',
|
||||||
|
`status` ENUM('active','inactive') NOT NULL DEFAULT 'active',
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_link` (`institution_id`, `transporter_id`),
|
||||||
|
KEY `idx_transporter` (`transporter_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------
|
||||||
|
-- 3. transit_org_admins — مشرفو المؤسسة (لوحة تحكم الويب)
|
||||||
|
-- -----------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `transit_org_admins` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`org_id` INT UNSIGNED NOT NULL,
|
||||||
|
`name` VARCHAR(120) NOT NULL,
|
||||||
|
`phone` VARCHAR(25) NOT NULL,
|
||||||
|
`role` ENUM('owner','transport_manager','dispatcher') NOT NULL DEFAULT 'transport_manager',
|
||||||
|
`permissions` JSON DEFAULT NULL COMMENT '{"routes":true,"drivers":true,"roster":true,"broadcast":true}',
|
||||||
|
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_phone_org` (`phone`, `org_id`),
|
||||||
|
KEY `idx_org` (`org_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------
|
||||||
|
-- 4. transit_sessions — جلسات تسجيل دخول مشرف الويب (OTP-based)
|
||||||
|
-- -----------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `transit_sessions` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`admin_id` INT UNSIGNED NOT NULL,
|
||||||
|
`org_id` INT UNSIGNED NOT NULL,
|
||||||
|
`token_hash` VARCHAR(64) NOT NULL COMMENT 'sha256 للـ session token',
|
||||||
|
`ip` VARCHAR(45) DEFAULT NULL,
|
||||||
|
`user_agent` VARCHAR(300) DEFAULT NULL,
|
||||||
|
`expires_at` TIMESTAMP NOT NULL,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_token` (`token_hash`),
|
||||||
|
KEY `idx_admin` (`admin_id`),
|
||||||
|
KEY `idx_expires` (`expires_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------
|
||||||
|
-- 5. transit_vehicles — الباصات والفانات المملوكة للمؤسسة
|
||||||
|
-- -----------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `transit_vehicles` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`org_id` INT UNSIGNED NOT NULL,
|
||||||
|
`plate` VARCHAR(30) NOT NULL,
|
||||||
|
`make` VARCHAR(50) DEFAULT NULL,
|
||||||
|
`model` VARCHAR(50) DEFAULT NULL,
|
||||||
|
`year` SMALLINT DEFAULT NULL,
|
||||||
|
`color` VARCHAR(30) DEFAULT NULL,
|
||||||
|
`capacity` TINYINT NOT NULL DEFAULT 30,
|
||||||
|
`vehicle_type` ENUM('bus','minibus','van','other') NOT NULL DEFAULT 'bus',
|
||||||
|
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
`notes` VARCHAR(300) DEFAULT NULL,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_plate` (`plate`),
|
||||||
|
KEY `idx_org` (`org_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------
|
||||||
|
-- 6. transit_drivers — سائقو الباصات (يُنشئهم مشرف المؤسسة)
|
||||||
|
-- -----------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `transit_drivers` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`org_id` INT UNSIGNED NOT NULL,
|
||||||
|
`name` VARCHAR(120) NOT NULL,
|
||||||
|
`phone` VARCHAR(25) NOT NULL,
|
||||||
|
`license_number` VARCHAR(50) DEFAULT NULL,
|
||||||
|
`license_img_url` VARCHAR(500) DEFAULT NULL,
|
||||||
|
`main_driver_id` VARCHAR(100) DEFAULT NULL COMMENT 'معرّف في جدول driver الرئيسي إن كان كابتن مشاوير أيضاً',
|
||||||
|
`invite_token` VARCHAR(64) DEFAULT NULL COMMENT 'رمز الدعوة للتفعيل الأولي',
|
||||||
|
`invite_sent_at` TIMESTAMP DEFAULT NULL,
|
||||||
|
`activated_at` TIMESTAMP DEFAULT NULL,
|
||||||
|
`status` ENUM('invited','active','suspended') NOT NULL DEFAULT 'invited',
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_phone_org` (`phone`, `org_id`),
|
||||||
|
KEY `idx_org` (`org_id`),
|
||||||
|
KEY `idx_invite_token` (`invite_token`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------
|
||||||
|
-- 7. transit_routes — خطوط النقل
|
||||||
|
-- -----------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `transit_routes` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`org_id` INT UNSIGNED NOT NULL,
|
||||||
|
`name_ar` VARCHAR(150) NOT NULL,
|
||||||
|
`name_en` VARCHAR(150) DEFAULT NULL,
|
||||||
|
`direction` ENUM('outbound','inbound','circular') NOT NULL DEFAULT 'outbound',
|
||||||
|
`polyline` TEXT DEFAULT NULL COMMENT 'Encoded polyline للمسار',
|
||||||
|
`distance_km` DECIMAL(7,2) DEFAULT NULL,
|
||||||
|
`duration_min` SMALLINT DEFAULT NULL,
|
||||||
|
`status` ENUM('draft','active','suspended') NOT NULL DEFAULT 'draft',
|
||||||
|
`created_by` INT UNSIGNED DEFAULT NULL COMMENT 'admin_id من أنشأه',
|
||||||
|
`approved_by` INT UNSIGNED DEFAULT NULL COMMENT 'admin_id من اعتمده (فريق سيرو)',
|
||||||
|
`approved_at` TIMESTAMP DEFAULT NULL,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_org_status` (`org_id`, `status`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------
|
||||||
|
-- 8. transit_stops — محطات الخط مع جيوفينس
|
||||||
|
-- -----------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `transit_stops` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`route_id` INT UNSIGNED NOT NULL,
|
||||||
|
`org_id` INT UNSIGNED NOT NULL,
|
||||||
|
`sequence` TINYINT NOT NULL COMMENT 'ترتيب المحطة في الخط (1، 2، 3…)',
|
||||||
|
`name_ar` VARCHAR(120) NOT NULL,
|
||||||
|
`name_en` VARCHAR(120) DEFAULT NULL,
|
||||||
|
`latitude` DECIMAL(10,7) NOT NULL,
|
||||||
|
`longitude` DECIMAL(10,7) NOT NULL,
|
||||||
|
`geofence_radius` SMALLINT NOT NULL DEFAULT 150 COMMENT 'نصف قطر الجيوفينس بالمتر',
|
||||||
|
`eta_offset_min` SMALLINT DEFAULT NULL COMMENT 'الإزاحة الزمنية عن وقت الانطلاق بالدقائق',
|
||||||
|
`is_major` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'محطة رئيسية (تظهر بارزة للطالب)',
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_route_seq` (`route_id`, `sequence`),
|
||||||
|
KEY `idx_org` (`org_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------
|
||||||
|
-- 9. transit_schedules — جداول انطلاق الخطوط
|
||||||
|
-- -----------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `transit_schedules` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`route_id` INT UNSIGNED NOT NULL,
|
||||||
|
`org_id` INT UNSIGNED NOT NULL,
|
||||||
|
`vehicle_id` INT UNSIGNED DEFAULT NULL COMMENT 'الباص المخصص لهذا الجدول (NULL = يُحدد يومياً)',
|
||||||
|
`driver_id` INT UNSIGNED DEFAULT NULL COMMENT 'السائق المخصص (NULL = يُحدد يومياً)',
|
||||||
|
`days_mask` TINYINT NOT NULL DEFAULT 62 COMMENT 'قناع الأيام: bit0=أحد…bit6=سبت (62=الأحد–الخميس)',
|
||||||
|
`departure_time` TIME NOT NULL,
|
||||||
|
`valid_from` DATE NOT NULL,
|
||||||
|
`valid_until` DATE DEFAULT NULL,
|
||||||
|
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_route_active` (`route_id`, `is_active`),
|
||||||
|
KEY `idx_org` (`org_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------
|
||||||
|
-- 10. transit_trips — الرحلات اليومية الفعلية
|
||||||
|
-- -----------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `transit_trips` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`schedule_id` INT UNSIGNED NOT NULL,
|
||||||
|
`route_id` INT UNSIGNED NOT NULL,
|
||||||
|
`org_id` INT UNSIGNED NOT NULL,
|
||||||
|
`driver_id` INT UNSIGNED NOT NULL,
|
||||||
|
`vehicle_id` INT UNSIGNED NOT NULL,
|
||||||
|
`trip_date` DATE NOT NULL,
|
||||||
|
`status` ENUM('scheduled','started','completed','cancelled','no_show') NOT NULL DEFAULT 'scheduled',
|
||||||
|
`started_at` TIMESTAMP DEFAULT NULL,
|
||||||
|
`completed_at` TIMESTAMP DEFAULT NULL,
|
||||||
|
`delay_minutes` TINYINT DEFAULT 0,
|
||||||
|
`delay_reason` VARCHAR(300) DEFAULT NULL,
|
||||||
|
`current_stop_seq` TINYINT DEFAULT NULL COMMENT 'آخر محطة جيوفينس مرّ عليها الباص',
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_schedule_date` (`schedule_id`, `trip_date`),
|
||||||
|
KEY `idx_route_date` (`route_id`, `trip_date`),
|
||||||
|
KEY `idx_org_date` (`org_id`, `trip_date`),
|
||||||
|
KEY `idx_driver_date` (`driver_id`, `trip_date`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------
|
||||||
|
-- 11. transit_enrollments — عضويات الطلاب/الموظفين في المؤسسة
|
||||||
|
-- -----------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `transit_enrollments` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`org_id` INT UNSIGNED NOT NULL,
|
||||||
|
`passenger_id` VARCHAR(100) NOT NULL COMMENT 'معرّف في جدول passengers الرئيسي',
|
||||||
|
`student_id` VARCHAR(50) NOT NULL COMMENT 'الرقم الجامعي/الوظيفي (encrypted)',
|
||||||
|
`member_name` VARCHAR(120) DEFAULT NULL COMMENT 'الاسم من كشف الجامعة',
|
||||||
|
`preferred_stop_id` INT UNSIGNED DEFAULT NULL COMMENT 'محطتي المفضلة',
|
||||||
|
`preferred_route_id` INT UNSIGNED DEFAULT NULL,
|
||||||
|
`verify_method` ENUM('api','roster_phone_match','roster_manual','manual_admin') NOT NULL DEFAULT 'roster_manual',
|
||||||
|
`status` ENUM('pending','active','suspended','expired') NOT NULL DEFAULT 'pending',
|
||||||
|
`verified_at` TIMESTAMP DEFAULT NULL,
|
||||||
|
`expires_at` DATE DEFAULT NULL COMMENT 'نهاية الفصل الدراسي',
|
||||||
|
`roster_id` INT UNSIGNED DEFAULT NULL COMMENT 'الكشف الذي جاءت منه العضوية',
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_org_passenger` (`org_id`, `passenger_id`),
|
||||||
|
KEY `idx_org_status` (`org_id`, `status`),
|
||||||
|
KEY `idx_passenger` (`passenger_id`),
|
||||||
|
KEY `idx_preferred_route` (`preferred_route_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------
|
||||||
|
-- 12. transit_rosters — سجلات استيراد كشوف الطلاب
|
||||||
|
-- -----------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `transit_rosters` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`org_id` INT UNSIGNED NOT NULL,
|
||||||
|
`uploaded_by` INT UNSIGNED NOT NULL COMMENT 'admin_id',
|
||||||
|
`filename` VARCHAR(200) DEFAULT NULL,
|
||||||
|
`total_rows` INT DEFAULT 0,
|
||||||
|
`matched_rows` INT DEFAULT 0 COMMENT 'طلاب طابق هاتفهم حساباً موجوداً',
|
||||||
|
`new_enrollments` INT DEFAULT 0,
|
||||||
|
`expired_count` INT DEFAULT 0 COMMENT 'طلاب تخرجوا وأُوقفت عضويتهم',
|
||||||
|
`semester` VARCHAR(30) DEFAULT NULL COMMENT 'مثال: 2026-S1',
|
||||||
|
`notes` VARCHAR(300) DEFAULT NULL,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_org` (`org_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------
|
||||||
|
-- 13. transit_guardians — أولياء الأمور (للمدارس)
|
||||||
|
-- -----------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `transit_guardians` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`enrollment_id` INT UNSIGNED NOT NULL,
|
||||||
|
`org_id` INT UNSIGNED NOT NULL,
|
||||||
|
`guardian_passenger_id` VARCHAR(100) NOT NULL COMMENT 'معرّف ولي الأمر في passengers الرئيسي',
|
||||||
|
`relation` ENUM('father','mother','guardian') NOT NULL DEFAULT 'guardian',
|
||||||
|
`notify_depart` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'إشعار عند انطلاق الباص',
|
||||||
|
`notify_board` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'إشعار عند صعود الطفل',
|
||||||
|
`notify_arrive` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'إشعار عند وصول الباص للمدرسة',
|
||||||
|
`notify_return` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'إشعار عند العودة للبيت',
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_enrollment_guardian` (`enrollment_id`, `guardian_passenger_id`),
|
||||||
|
KEY `idx_org` (`org_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------
|
||||||
|
-- 14. transit_broadcasts — إعلانات المشرف للمشتركين
|
||||||
|
-- -----------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `transit_broadcasts` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`org_id` INT UNSIGNED NOT NULL,
|
||||||
|
`sent_by` INT UNSIGNED NOT NULL COMMENT 'admin_id',
|
||||||
|
`target_type` ENUM('all','route','role') NOT NULL DEFAULT 'all',
|
||||||
|
`target_id` INT UNSIGNED DEFAULT NULL COMMENT 'route_id عند target_type=route',
|
||||||
|
`title_ar` VARCHAR(200) DEFAULT NULL,
|
||||||
|
`body_ar` TEXT NOT NULL,
|
||||||
|
`fcm_topic` VARCHAR(150) DEFAULT NULL COMMENT 'الموضوع المستخدم للإرسال',
|
||||||
|
`sent_at` TIMESTAMP DEFAULT NULL,
|
||||||
|
`delivery_count` INT DEFAULT NULL,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_org` (`org_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------
|
||||||
|
-- 15. transit_boardings — إثبات الصعود والنزول (مرحلة ثانية)
|
||||||
|
-- -----------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `transit_boardings` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`trip_id` INT UNSIGNED NOT NULL,
|
||||||
|
`enrollment_id` INT UNSIGNED NOT NULL,
|
||||||
|
`stop_id` INT UNSIGNED DEFAULT NULL,
|
||||||
|
`boarded_at` TIMESTAMP DEFAULT NULL,
|
||||||
|
`alighted_at` TIMESTAMP DEFAULT NULL,
|
||||||
|
`method` ENUM('geofence','qr','driver_tap') NOT NULL DEFAULT 'geofence',
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_trip` (`trip_id`),
|
||||||
|
KEY `idx_enrollment` (`enrollment_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------
|
||||||
|
-- 16. transit_otp_log — سجل OTPs لمشرفي الويب (تنظيف دوري)
|
||||||
|
-- -----------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `transit_otp_log` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`phone` VARCHAR(25) NOT NULL,
|
||||||
|
`otp_hash` VARCHAR(64) NOT NULL COMMENT 'sha256 للكود',
|
||||||
|
`expires_at` TIMESTAMP NOT NULL,
|
||||||
|
`used` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_phone` (`phone`),
|
||||||
|
KEY `idx_expires` (`expires_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------
|
||||||
|
-- ملاحظات الـ Redis (keys تُكتب من PHP — ليست في SQL)
|
||||||
|
-- transit:trip:{trip_id}:pos → آخر موقع GPS للباص (Hash: lat,lng,ts)
|
||||||
|
-- transit:trip:{trip_id}:status → حالة الرحلة الحية
|
||||||
|
-- transit:otp:{phone} → OTP مشرف الويب (TTL 300s)
|
||||||
|
-- transit:session:{token_hash} → admin_id+org_id (TTL 86400s)
|
||||||
|
-- transit:driver_mode:{driver_id} → وضع السائق الحالي (bus/ride), TTL session
|
||||||
|
-- FCM Topics: transit_route_{route_id} ← كل مشتركي خط
|
||||||
|
-- -----------------------------------------------------------------
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
// transit/trip/delay.php — السائق يبلغ عن تأخير
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../transit/connect_app.php';
|
||||||
|
|
||||||
|
requireTransitFields(['trip_id', 'driver_transit_id', 'delay_minutes']);
|
||||||
|
|
||||||
|
$tripId = filterRequest('trip_id', 'int');
|
||||||
|
$driverId = filterRequest('driver_transit_id', 'int');
|
||||||
|
$delay = min((int)filterRequest('delay_minutes'), 120);
|
||||||
|
$reason = filterRequest('reason');
|
||||||
|
|
||||||
|
$st = $transit_con->prepare(
|
||||||
|
"SELECT t.id, t.route_id, r.name_ar AS route_name
|
||||||
|
FROM transit_trips t JOIN transit_routes r ON r.id=t.route_id
|
||||||
|
WHERE t.id=? AND t.driver_id=? AND t.status='started' LIMIT 1"
|
||||||
|
);
|
||||||
|
$st->execute([$tripId, $driverId]);
|
||||||
|
$trip = $st->fetch();
|
||||||
|
if (!$trip) jsonError('Active trip not found', 404);
|
||||||
|
|
||||||
|
$transit_con->prepare(
|
||||||
|
"UPDATE transit_trips SET delay_minutes=?, delay_reason=?, updated_at=NOW() WHERE id=?"
|
||||||
|
)->execute([$delay, $reason, $tripId]);
|
||||||
|
|
||||||
|
transitSendTopicNotification(
|
||||||
|
transitRouteTopic((int)$trip['route_id']),
|
||||||
|
'تأخير في الباص',
|
||||||
|
'خط ' . $trip['route_name'] . ' متأخر ' . $delay . ' دقيقة' . ($reason ? " — $reason" : ''),
|
||||||
|
['type' => 'transit_delay', 'trip_id' => (string)$tripId, 'delay' => (string)$delay]
|
||||||
|
);
|
||||||
|
|
||||||
|
jsonSuccess(['delay_minutes' => $delay], 'Delay reported and passengers notified');
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
// transit/trip/end.php — السائق ينهي الرحلة
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../transit/connect_app.php';
|
||||||
|
|
||||||
|
requireTransitFields(['trip_id', 'driver_transit_id']);
|
||||||
|
|
||||||
|
$tripId = filterRequest('trip_id', 'int');
|
||||||
|
$driverId = filterRequest('driver_transit_id', 'int');
|
||||||
|
|
||||||
|
$st = $transit_con->prepare(
|
||||||
|
"SELECT id, route_id, status FROM transit_trips WHERE id=? AND driver_id=? LIMIT 1"
|
||||||
|
);
|
||||||
|
$st->execute([$tripId, $driverId]);
|
||||||
|
$trip = $st->fetch();
|
||||||
|
|
||||||
|
if (!$trip) jsonError('Trip not found', 404);
|
||||||
|
if ($trip['status'] !== 'started') jsonError('Trip is not in started state', 409);
|
||||||
|
|
||||||
|
$transit_con->prepare(
|
||||||
|
"UPDATE transit_trips SET status='completed', completed_at=NOW(), updated_at=NOW() WHERE id=?"
|
||||||
|
)->execute([$tripId]);
|
||||||
|
|
||||||
|
global $redis;
|
||||||
|
if ($redis) $redis->del("transit:trip:{$tripId}:status");
|
||||||
|
|
||||||
|
// موقع الباص + ملكية الرحلة على Redis سيرفر الموقع
|
||||||
|
transitClearTripOwner($tripId);
|
||||||
|
global $redisLocation;
|
||||||
|
if ($redisLocation) $redisLocation->del("transit:trip:{$tripId}:pos");
|
||||||
|
|
||||||
|
jsonSuccess(['trip_id' => $tripId, 'status' => 'completed'], 'Trip completed');
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
// transit/trip/live.php — الراكب يسأل عن موقع الباص الحي
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../transit/connect_app.php';
|
||||||
|
|
||||||
|
$tripId = filterRequest('trip_id', 'int');
|
||||||
|
$routeId = filterRequest('route_id', 'int');
|
||||||
|
|
||||||
|
if (!$tripId && $routeId) {
|
||||||
|
$st = $transit_con->prepare(
|
||||||
|
"SELECT id FROM transit_trips WHERE route_id=? AND trip_date=? AND status='started' LIMIT 1"
|
||||||
|
);
|
||||||
|
$st->execute([$routeId, date('Y-m-d')]);
|
||||||
|
$row = $st->fetch();
|
||||||
|
$tripId = $row ? (int)$row['id'] : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$tripId) jsonError('No active trip found', 404);
|
||||||
|
|
||||||
|
$st = $transit_con->prepare(
|
||||||
|
"SELECT t.id, t.org_id, t.status, t.delay_minutes, t.current_stop_seq, t.started_at,
|
||||||
|
r.name_ar AS route_name, sc.departure_time, d.name AS driver_name
|
||||||
|
FROM transit_trips t
|
||||||
|
JOIN transit_routes r ON r.id = t.route_id
|
||||||
|
JOIN transit_schedules sc ON sc.id = t.schedule_id
|
||||||
|
JOIN transit_drivers d ON d.id = t.driver_id
|
||||||
|
WHERE t.id=? LIMIT 1"
|
||||||
|
);
|
||||||
|
$st->execute([$tripId]);
|
||||||
|
$trip = $st->fetch();
|
||||||
|
if (!$trip) jsonError('Trip not found', 404);
|
||||||
|
|
||||||
|
// الراكب يحتاج عضوية نشطة في مؤسسة هذا الخط قبل رؤية موقع الباص
|
||||||
|
$chk = $transit_con->prepare(
|
||||||
|
"SELECT id FROM transit_enrollments WHERE org_id=? AND passenger_id=? AND status='active' LIMIT 1"
|
||||||
|
);
|
||||||
|
$chk->execute([$trip['org_id'], $transit_user_id]);
|
||||||
|
if (!$chk->fetch()) jsonError('Active enrollment required', 403);
|
||||||
|
|
||||||
|
$stStops = $transit_con->prepare(
|
||||||
|
"SELECT s.id, s.sequence, s.name_ar, s.latitude, s.longitude,
|
||||||
|
s.geofence_radius, s.eta_offset_min, s.is_major
|
||||||
|
FROM transit_stops s
|
||||||
|
JOIN transit_trips t ON t.route_id = s.route_id
|
||||||
|
WHERE t.id=? ORDER BY s.sequence ASC"
|
||||||
|
);
|
||||||
|
$stStops->execute([$tripId]);
|
||||||
|
|
||||||
|
jsonSuccess([
|
||||||
|
'trip' => $trip,
|
||||||
|
'bus_position' => transitGetBusPosition($tripId),
|
||||||
|
'stops' => $stStops->fetchAll(),
|
||||||
|
]);
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
// transit/trip/start.php — السائق يبدأ الرحلة
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../transit/connect_app.php';
|
||||||
|
|
||||||
|
requireTransitFields(['trip_id', 'driver_transit_id', 'lat', 'lng']);
|
||||||
|
|
||||||
|
$tripId = filterRequest('trip_id', 'int');
|
||||||
|
$driverId = filterRequest('driver_transit_id', 'int');
|
||||||
|
$lat = (float)filterRequest('lat');
|
||||||
|
$lng = (float)filterRequest('lng');
|
||||||
|
|
||||||
|
$st = $transit_con->prepare(
|
||||||
|
"SELECT t.id, t.route_id, t.status, r.name_ar AS route_name, d.main_driver_id
|
||||||
|
FROM transit_trips t
|
||||||
|
JOIN transit_routes r ON r.id = t.route_id
|
||||||
|
JOIN transit_drivers d ON d.id = t.driver_id
|
||||||
|
WHERE t.id=? AND t.driver_id=? LIMIT 1"
|
||||||
|
);
|
||||||
|
$st->execute([$tripId, $driverId]);
|
||||||
|
$trip = $st->fetch();
|
||||||
|
|
||||||
|
if (!$trip) jsonError('Trip not found', 404);
|
||||||
|
if ($trip['status'] === 'started') jsonError('Trip already started', 409);
|
||||||
|
if ($trip['status'] === 'completed') jsonError('Trip already completed', 409);
|
||||||
|
if ($trip['status'] === 'cancelled') jsonError('Trip was cancelled', 409);
|
||||||
|
if (!$trip['main_driver_id']) jsonError('Driver has no linked main account — cannot start live tracking', 422);
|
||||||
|
|
||||||
|
$transit_con->prepare(
|
||||||
|
"UPDATE transit_trips SET status='started', started_at=NOW(), current_stop_seq=1 WHERE id=?"
|
||||||
|
)->execute([$tripId]);
|
||||||
|
|
||||||
|
transitUpdateBusPosition($tripId, $lat, $lng);
|
||||||
|
|
||||||
|
// تخزين ملكية الرحلة — يتحقق منها driver_socket قبل أي بثّ حي للراكبين
|
||||||
|
transitSetTripOwner($tripId, (string)$trip['main_driver_id'], (int)$trip['route_id']);
|
||||||
|
|
||||||
|
global $redis;
|
||||||
|
if ($redis) {
|
||||||
|
$redis->set("transit:trip:{$tripId}:status", 'started');
|
||||||
|
$redis->expire("transit:trip:{$tripId}:status", 86400);
|
||||||
|
}
|
||||||
|
|
||||||
|
transitSendTopicNotification(
|
||||||
|
transitRouteTopic((int)$trip['route_id']),
|
||||||
|
'الباص انطلق الآن',
|
||||||
|
'خط ' . $trip['route_name'] . ' بدأ رحلته',
|
||||||
|
['type' => 'transit_started', 'trip_id' => (string)$tripId]
|
||||||
|
);
|
||||||
|
|
||||||
|
jsonSuccess(['trip_id' => $tripId, 'status' => 'started', 'started_at' => date('Y-m-d H:i:s')], 'Trip started');
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
// transit/trip/today.php — السائق يجلب رحلاته اليوم (تُنشأ تلقائياً من الجداول)
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../transit/connect_app.php';
|
||||||
|
|
||||||
|
$driverTransitId = filterRequest('driver_transit_id', 'int');
|
||||||
|
if (!$driverTransitId) jsonError('driver_transit_id is required', 400);
|
||||||
|
|
||||||
|
$chk = $transit_con->prepare(
|
||||||
|
"SELECT id, org_id FROM transit_drivers WHERE id=? AND status='active' LIMIT 1"
|
||||||
|
);
|
||||||
|
$chk->execute([$driverTransitId]);
|
||||||
|
$driver = $chk->fetch();
|
||||||
|
if (!$driver) jsonError('Driver not found or not active', 403);
|
||||||
|
|
||||||
|
$today = date('Y-m-d');
|
||||||
|
|
||||||
|
// أنشئ رحلات اليوم من الجداول النشطة إن لم تُنشأ بعد
|
||||||
|
$stScheds = $transit_con->prepare(
|
||||||
|
"SELECT id AS schedule_id, route_id, vehicle_id, days_mask
|
||||||
|
FROM transit_schedules
|
||||||
|
WHERE driver_id=? AND org_id=? AND is_active=1
|
||||||
|
AND valid_from <= ? AND (valid_until IS NULL OR valid_until >= ?)"
|
||||||
|
);
|
||||||
|
$stScheds->execute([$driverTransitId, $driver['org_id'], $today, $today]);
|
||||||
|
|
||||||
|
foreach ($stScheds->fetchAll() as $sch) {
|
||||||
|
if (!transitDayActive((int)$sch['days_mask'])) continue;
|
||||||
|
$ex = $transit_con->prepare("SELECT id FROM transit_trips WHERE schedule_id=? AND trip_date=? LIMIT 1");
|
||||||
|
$ex->execute([$sch['schedule_id'], $today]);
|
||||||
|
if (!$ex->fetch()) {
|
||||||
|
$transit_con->prepare(
|
||||||
|
"INSERT INTO transit_trips
|
||||||
|
(schedule_id, route_id, org_id, driver_id, vehicle_id, trip_date, status)
|
||||||
|
VALUES (?,?,?,?,?,?,'scheduled')"
|
||||||
|
)->execute([
|
||||||
|
$sch['schedule_id'], $sch['route_id'], $driver['org_id'],
|
||||||
|
$driverTransitId, $sch['vehicle_id'], $today,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// اجلب رحلات اليوم مع التفاصيل
|
||||||
|
$stTrips = $transit_con->prepare(
|
||||||
|
"SELECT t.id, t.route_id, t.status, t.delay_minutes, t.current_stop_seq,
|
||||||
|
t.started_at, t.completed_at,
|
||||||
|
r.name_ar AS route_name, r.polyline,
|
||||||
|
sc.departure_time,
|
||||||
|
v.plate AS vehicle_plate, v.capacity
|
||||||
|
FROM transit_trips t
|
||||||
|
JOIN transit_routes r ON r.id = t.route_id
|
||||||
|
JOIN transit_schedules sc ON sc.id = t.schedule_id
|
||||||
|
LEFT JOIN transit_vehicles v ON v.id = t.vehicle_id
|
||||||
|
WHERE t.driver_id=? AND t.trip_date=?
|
||||||
|
ORDER BY sc.departure_time ASC"
|
||||||
|
);
|
||||||
|
$stTrips->execute([$driverTransitId, $today]);
|
||||||
|
$trips = $stTrips->fetchAll();
|
||||||
|
|
||||||
|
$stStops = $transit_con->prepare(
|
||||||
|
"SELECT sequence, name_ar, latitude, longitude, geofence_radius, eta_offset_min
|
||||||
|
FROM transit_stops WHERE route_id=? ORDER BY sequence ASC"
|
||||||
|
);
|
||||||
|
foreach ($trips as &$trip) {
|
||||||
|
$stStops->execute([$trip['route_id']]);
|
||||||
|
$trip['stops'] = $stStops->fetchAll();
|
||||||
|
}
|
||||||
|
unset($trip);
|
||||||
|
|
||||||
|
jsonSuccess(['date' => $today, 'trips' => $trips]);
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
// transit/trip/update_position.php
|
||||||
|
// ⚠️ مهجور (fallback فقط): المسار الأساسي لموقع الباص هو WebSocket:
|
||||||
|
// سائق الباص يبعث socket.emit('update_bus_location', {...}) إلى driver_socket:2020
|
||||||
|
// الذي يخزّن الموقع ويبثّه حياً لكل ركاب الخط عبر passenger_socket.
|
||||||
|
// هذا الـ endpoint يكتب الموقع في Redis فقط ولا يبثّه للركاب — لا تستخدمه
|
||||||
|
// إلا كخطة بديلة عند تعذّر السوكت. الموقع يُخزَّن على Redis سيرفر الموقع.
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../core/bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../functions.php';
|
||||||
|
|
||||||
|
try { $transit_con = Database::get('transit'); }
|
||||||
|
catch (Exception $e) { http_response_code(503); exit; }
|
||||||
|
|
||||||
|
$tripId = filterRequest('trip_id', 'int');
|
||||||
|
$driverId = filterRequest('driver_transit_id', 'int');
|
||||||
|
$lat = (float)filterRequest('lat');
|
||||||
|
$lng = (float)filterRequest('lng');
|
||||||
|
$stopSeq = filterRequest('current_stop_seq', 'int');
|
||||||
|
|
||||||
|
if (!$tripId || !$driverId || !$lat || !$lng) {
|
||||||
|
jsonError('Missing required fields', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
transitUpdateBusPosition($tripId, $lat, $lng);
|
||||||
|
|
||||||
|
if ($stopSeq !== null) {
|
||||||
|
$transit_con->prepare(
|
||||||
|
"UPDATE transit_trips SET current_stop_seq=?, updated_at=NOW() WHERE id=? AND driver_id=?"
|
||||||
|
)->execute([$stopSeq, $tripId, $driverId]);
|
||||||
|
|
||||||
|
global $redis;
|
||||||
|
if ($redis) {
|
||||||
|
$redis->publish("transit:trip:{$tripId}:stop", json_encode(['seq' => $stopSeq, 'ts' => time()]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonSuccess(['ok' => true]);
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
// transit/vehicle/add.php — إضافة باص
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../transit/connect_admin.php';
|
||||||
|
|
||||||
|
$plate = strtoupper(filterRequest('plate') ?? '');
|
||||||
|
if (!$plate) jsonError('plate is required');
|
||||||
|
|
||||||
|
$capacity = filterRequest('capacity', 'int') ?? 30;
|
||||||
|
$vType = filterRequest('vehicle_type') ?: 'bus';
|
||||||
|
if (!in_array($vType, ['bus','minibus','van','other'])) $vType = 'bus';
|
||||||
|
|
||||||
|
$chk = $transit_con->prepare("SELECT id FROM transit_vehicles WHERE plate=? LIMIT 1");
|
||||||
|
$chk->execute([$plate]);
|
||||||
|
if ($chk->fetch()) jsonError('Vehicle plate already registered', 409);
|
||||||
|
|
||||||
|
$transit_con->prepare(
|
||||||
|
"INSERT INTO transit_vehicles (org_id,plate,make,model,year,color,capacity,vehicle_type,notes)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?)"
|
||||||
|
)->execute([
|
||||||
|
$transit_org_id, $plate,
|
||||||
|
filterRequest('make'), filterRequest('model'),
|
||||||
|
filterRequest('year','int'), filterRequest('color'),
|
||||||
|
$capacity, $vType, filterRequest('notes'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
jsonSuccess(['vehicle_id' => (int)$transit_con->lastInsertId()], 'Vehicle added');
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
// transit/vehicle/list.php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../transit/connect_admin.php';
|
||||||
|
|
||||||
|
$st = $transit_con->prepare(
|
||||||
|
"SELECT id, plate, make, model, year, color, capacity, vehicle_type, is_active, notes, created_at
|
||||||
|
FROM transit_vehicles WHERE org_id=? ORDER BY plate ASC"
|
||||||
|
);
|
||||||
|
$st->execute([$transit_org_id]);
|
||||||
|
|
||||||
|
jsonSuccess(['vehicles' => $st->fetchAll()]);
|
||||||
@@ -182,6 +182,61 @@ function forwardLocationToPassengerSocket(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 🚌 Forward موقع الباص → سيرفر الراكب (بثّ لغرفة الخط، ASYNC + throttle)
|
||||||
|
// نفس آلية forwardLocationToPassengerSocket لكن للبثّ الجماعي
|
||||||
|
// (باص واحد → كل ركاب الخط) بدل (سائق → راكب واحد)
|
||||||
|
// ============================================================
|
||||||
|
function forwardBusLocationToRoute(
|
||||||
|
int $tripId,
|
||||||
|
int $routeId,
|
||||||
|
array $payload,
|
||||||
|
string $internalKey,
|
||||||
|
array &$busThrottle
|
||||||
|
): void {
|
||||||
|
if ($routeId <= 0) return;
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
$last = $busThrottle[$tripId] ?? null;
|
||||||
|
|
||||||
|
if ($last !== null) {
|
||||||
|
$timeDiff = $now - $last['ts'];
|
||||||
|
$dist = haversineDistance(
|
||||||
|
$last['lat'], $last['lng'],
|
||||||
|
(float)$payload['latitude'], (float)$payload['longitude']
|
||||||
|
);
|
||||||
|
if ($dist < FORWARD_MIN_METERS && $timeDiff < FORWARD_MAX_SECONDS) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$busThrottle[$tripId] = [
|
||||||
|
'ts' => $now,
|
||||||
|
'lat' => (float)$payload['latitude'],
|
||||||
|
'lng' => (float)$payload['longitude'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$passengerSocketUrl = getenv('PASSENGER_SOCKET_INTERNAL_URL') ?: 'http://127.0.0.1:3031';
|
||||||
|
$http = new AsyncHttp();
|
||||||
|
$http->request(
|
||||||
|
$passengerSocketUrl,
|
||||||
|
[
|
||||||
|
'method' => 'POST',
|
||||||
|
'data' => http_build_query([
|
||||||
|
'action' => 'broadcast_bus_location',
|
||||||
|
'route_id' => $routeId,
|
||||||
|
'payload' => json_encode($payload),
|
||||||
|
]),
|
||||||
|
'headers' => [
|
||||||
|
'Content-Type' => 'application/x-www-form-urlencoded',
|
||||||
|
'x-internal-key' => $internalKey,
|
||||||
|
'Connection' => 'close',
|
||||||
|
],
|
||||||
|
'timeout' => 3,
|
||||||
|
],
|
||||||
|
null,
|
||||||
|
fn(\Exception $e) => logMsg('⚠️ Bus forward failed: ' . $e->getMessage())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 📲 FCM (ASYNC)
|
// 📲 FCM (ASYNC)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -215,9 +270,10 @@ function sendFCM_Async(string $token, string $title, string $body, array $rideDa
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
$connectedDrivers = [];
|
$connectedDrivers = [];
|
||||||
$active_orders_drivers = [];
|
$active_orders_drivers = [];
|
||||||
$driverState = [];
|
$driverState = [];
|
||||||
$fwdThrottle = [];
|
$fwdThrottle = [];
|
||||||
$eventBuffer = []; // 🚀 Level 2: مصفوفة تجميع الأحداث لـ Redis
|
$eventBuffer = []; // 🚀 Level 2: مصفوفة تجميع الأحداث لـ Redis
|
||||||
|
$busFwdThrottle = []; // 🚌 throttle بثّ موقع الباص لكل رحلة (trip_id)
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 🚀 Socket.IO — بورت 2020
|
// 🚀 Socket.IO — بورت 2020
|
||||||
@@ -592,6 +648,21 @@ $io->on('workerStart', function () use ($io, $INTERNAL_KEY) {
|
|||||||
$connection->send('Error');
|
$connection->send('Error');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 8. 🚌 Get Bus Position (آخر موقع للباص — للتحميل الأولي) ─
|
||||||
|
// يستدعيه الباك اند (transit/trip/live.php) ليعطي الراكب آخر
|
||||||
|
// موقع معروف فوراً عند فتح الخط، قبل أول بثّ حي عبر السوكت.
|
||||||
|
} elseif ($action === 'get_bus_position') {
|
||||||
|
$tripId = (int)($post['trip_id'] ?? 0);
|
||||||
|
if ($tripId <= 0 || !$redis) {
|
||||||
|
$connection->send(json_encode(['status' => false, 'data' => null]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$pos = $redis->hgetall("transit:trip:$tripId:pos");
|
||||||
|
$connection->send(json_encode([
|
||||||
|
'status' => !empty($pos),
|
||||||
|
'data' => $pos ?: null,
|
||||||
|
]));
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
$connection->send('Unknown action');
|
$connection->send('Unknown action');
|
||||||
}
|
}
|
||||||
@@ -604,7 +675,7 @@ $io->on('workerStart', function () use ($io, $INTERNAL_KEY) {
|
|||||||
// B. WebSocket Events للسائقين
|
// B. WebSocket Events للسائقين
|
||||||
// ============================================================
|
// ============================================================
|
||||||
$io->on('connection', function ($socket) use ($INTERNAL_KEY) {
|
$io->on('connection', function ($socket) use ($INTERNAL_KEY) {
|
||||||
global $connectedDrivers, $driverState, $fwdThrottle, $eventBuffer;
|
global $connectedDrivers, $driverState, $fwdThrottle, $eventBuffer, $busFwdThrottle;
|
||||||
|
|
||||||
$query = $socket->handshake['query'] ?? [];
|
$query = $socket->handshake['query'] ?? [];
|
||||||
$driverId = $query['driver_id'] ?? null;
|
$driverId = $query['driver_id'] ?? null;
|
||||||
@@ -826,13 +897,73 @@ $io->on('connection', function ($socket) use ($INTERNAL_KEY) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── 🚌 وضع الباص (مواصلاتي) ────────────────────────────────
|
||||||
|
// سائق الباص هو سائق عادي (JWT role=driver) لكنه في وضع الباص لا
|
||||||
|
// يدخل حوض الرحلات (geo:drivers:*). يبعث update_bus_location فقط.
|
||||||
|
// نخزّن آخر موقع في Redis سيرفر الموقع + نبثّه لغرفة الخط.
|
||||||
|
$socket->on('update_bus_location', function ($data)
|
||||||
|
use ($driverId, $INTERNAL_KEY, &$busFwdThrottle)
|
||||||
|
{
|
||||||
|
$data = (array) $data;
|
||||||
|
|
||||||
|
$tripId = (int)($data['trip_id'] ?? 0);
|
||||||
|
$lat = isset($data['lat']) ? (float)$data['lat'] : null;
|
||||||
|
$lng = isset($data['lng']) ? (float)$data['lng'] : null;
|
||||||
|
$heading = (float)($data['heading'] ?? 0);
|
||||||
|
$speed = (float)($data['speed'] ?? 0);
|
||||||
|
$stopSeq = isset($data['current_stop_seq']) ? (int)$data['current_stop_seq'] : null;
|
||||||
|
|
||||||
|
if (!$tripId || $lat === null || $lng === null) return;
|
||||||
|
|
||||||
|
$redis = getRedis();
|
||||||
|
if (!$redis) return;
|
||||||
|
|
||||||
|
// ── تحقق الملكية: هذه الرحلة فعلاً مسندة لهذا السائق؟ ────────
|
||||||
|
// الكاش يُكتب من transit/trip/start.php عند بدء الرحلة (transitSetTripOwner)
|
||||||
|
// ويُحذف عند إنهائها. أي سائق آخر (حتى لو خمّن trip_id صحيح) يُرفض هنا،
|
||||||
|
// ولا نثق بـ route_id القادم من العميل — نأخذه دائماً من الكاش الموثوق.
|
||||||
|
$owner = $redis->hgetall("transit:trip:$tripId:owner");
|
||||||
|
if (empty($owner) || (string)($owner['driver_id'] ?? '') !== (string)$driverId) {
|
||||||
|
logMsg("🚫 update_bus_location rejected: driver #$driverId is not the owner of trip #$tripId");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$routeId = (int)($owner['route_id'] ?? 0);
|
||||||
|
if ($routeId <= 0) return;
|
||||||
|
|
||||||
|
// 1. آخر موقع في Redis سيرفر الموقع (بدون بادئة — يقرؤه الباك اند عبر $redisLocation)
|
||||||
|
$posKey = "transit:trip:$tripId:pos";
|
||||||
|
$redis->hmset($posKey, [
|
||||||
|
'lat' => $lat,
|
||||||
|
'lng' => $lng,
|
||||||
|
'heading' => $heading,
|
||||||
|
'speed' => $speed,
|
||||||
|
'driver_id' => $driverId,
|
||||||
|
'ts' => time(),
|
||||||
|
]);
|
||||||
|
$redis->expire($posKey, 86400);
|
||||||
|
|
||||||
|
// 2. بثّ الموقع لكل ركاب الخط عبر سيرفر الراكب (throttled)
|
||||||
|
forwardBusLocationToRoute($tripId, $routeId, [
|
||||||
|
'trip_id' => $tripId,
|
||||||
|
'route_id' => $routeId,
|
||||||
|
'latitude' => $lat,
|
||||||
|
'longitude' => $lng,
|
||||||
|
'heading' => $heading,
|
||||||
|
'speed' => $speed,
|
||||||
|
'current_stop_seq' => $stopSeq,
|
||||||
|
'driver_id' => $driverId,
|
||||||
|
], $INTERNAL_KEY, $busFwdThrottle);
|
||||||
|
});
|
||||||
|
|
||||||
$socket->on('disconnect', function () use ($driverId) {
|
$socket->on('disconnect', function () use ($driverId) {
|
||||||
global $connectedDrivers, $driverState, $fwdThrottle;
|
global $connectedDrivers, $driverState, $fwdThrottle;
|
||||||
|
|
||||||
unset($connectedDrivers[$driverId]);
|
unset($connectedDrivers[$driverId]);
|
||||||
unset($driverState[$driverId]);
|
unset($driverState[$driverId]);
|
||||||
unset($fwdThrottle[$driverId]);
|
unset($fwdThrottle[$driverId]);
|
||||||
|
// ملاحظة: $busFwdThrottle مُفهرس بـ trip_id (لا driver_id) — يُستبدَل
|
||||||
|
// تلقائياً في الرحلة التالية، فلا حاجة لحذفه هنا.
|
||||||
|
|
||||||
logMsg("❌ Driver Disconnected: #$driverId");
|
logMsg("❌ Driver Disconnected: #$driverId");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -134,6 +134,28 @@ $io->on('workerStart', function () use ($io, $INTERNAL_KEY, $INTERNAL_PORT) {
|
|||||||
|
|
||||||
$connection->send('OK');
|
$connection->send('OK');
|
||||||
|
|
||||||
|
} elseif ($action === 'broadcast_bus_location') {
|
||||||
|
|
||||||
|
// 🚌 بثّ موقع الباص لكل ركاب الخط المشتركين (مواصلاتي)
|
||||||
|
// يصل من driver_socket بعد أن يبعث سائق الباص update_bus_location
|
||||||
|
$routeId = $post['route_id'] ?? null;
|
||||||
|
$rawPayload = $post['payload'] ?? null;
|
||||||
|
|
||||||
|
if (!$routeId || !$rawPayload) {
|
||||||
|
socket_log("[HTTP_ERROR] Missing route_id or payload for action: broadcast_bus_location", $post);
|
||||||
|
$connection->send('Error: Missing route_id or payload');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = is_string($rawPayload)
|
||||||
|
? (json_decode($rawPayload, true) ?? $rawPayload)
|
||||||
|
: $rawPayload;
|
||||||
|
|
||||||
|
socket_log("[HTTP_SUCCESS] Emitting 'bus_location_update' to route #$routeId", $payload);
|
||||||
|
$io->to('transit_route_' . $routeId)->emit('bus_location_update', $payload);
|
||||||
|
|
||||||
|
$connection->send('OK');
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
socket_log("[HTTP_WARNING] Unknown action received: $action", $post);
|
socket_log("[HTTP_WARNING] Unknown action received: $action", $post);
|
||||||
$connection->send('Unknown action: ' . $action);
|
$connection->send('Unknown action: ' . $action);
|
||||||
@@ -183,6 +205,27 @@ $io->on('connection', function ($socket) {
|
|||||||
// socket_log("[SOCKET_HEARTBEAT] Received from Passenger #$passengerId");
|
// socket_log("[SOCKET_HEARTBEAT] Received from Passenger #$passengerId");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🚌 اشتراك الراكب في بثّ موقع باص خط (مواصلاتي)
|
||||||
|
// يُستدعى عند فتح الراكب لخط في قائمة مواصلاتي
|
||||||
|
$socket->on('subscribe_transit_route', function ($data) use ($socket, $passengerId) {
|
||||||
|
$data = (array) $data;
|
||||||
|
$routeId = (int)($data['route_id'] ?? 0);
|
||||||
|
if ($routeId <= 0) return;
|
||||||
|
|
||||||
|
$socket->join('transit_route_' . $routeId);
|
||||||
|
socket_log("[TRANSIT] Passenger #$passengerId subscribed to route #$routeId");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🚌 إلغاء الاشتراك عند إغلاق الراكب للخط
|
||||||
|
$socket->on('unsubscribe_transit_route', function ($data) use ($socket, $passengerId) {
|
||||||
|
$data = (array) $data;
|
||||||
|
$routeId = (int)($data['route_id'] ?? 0);
|
||||||
|
if ($routeId <= 0) return;
|
||||||
|
|
||||||
|
$socket->leave('transit_route_' . $routeId);
|
||||||
|
socket_log("[TRANSIT] Passenger #$passengerId unsubscribed from route #$routeId");
|
||||||
|
});
|
||||||
|
|
||||||
$socket->on('disconnect', function () use ($passengerId, $clientIp) {
|
$socket->on('disconnect', function () use ($passengerId, $clientIp) {
|
||||||
socket_log("[SOCKET_DISCONNECTED] Passenger Disconnected: #$passengerId (IP: $clientIp)");
|
socket_log("[SOCKET_DISCONNECTED] Passenger Disconnected: #$passengerId (IP: $clientIp)");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
// transit_admin_controller.dart — تحكم شاشات مواصلاتي (لوحة إدارة سيرو)
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
|
||||||
|
import 'transit_admin_models.dart';
|
||||||
|
import 'transit_admin_service.dart';
|
||||||
|
|
||||||
|
class TransitAdminController extends GetxController {
|
||||||
|
bool isLoadingList = false;
|
||||||
|
List<TransitOrgSummary> orgs = [];
|
||||||
|
String? filterCountry;
|
||||||
|
String? filterType;
|
||||||
|
String? filterStatus;
|
||||||
|
String searchQuery = '';
|
||||||
|
|
||||||
|
bool isLoadingDetails = false;
|
||||||
|
TransitOrgDetails? selectedOrgDetails;
|
||||||
|
|
||||||
|
bool isCreating = false;
|
||||||
|
|
||||||
|
// ── مشرفو المؤسسة ────────────────────────────────────────────
|
||||||
|
bool isLoadingAdmins = false;
|
||||||
|
List<Map<String, dynamic>> orgAdmins = [];
|
||||||
|
bool isSavingAdmin = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
super.onInit();
|
||||||
|
fetchOrgs();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fetchOrgs() async {
|
||||||
|
isLoadingList = true;
|
||||||
|
update();
|
||||||
|
|
||||||
|
final res = await TransitAdminService.listOrgs(
|
||||||
|
country: filterCountry,
|
||||||
|
type: filterType,
|
||||||
|
contractStatus: filterStatus,
|
||||||
|
search: searchQuery,
|
||||||
|
);
|
||||||
|
if (res.success) orgs = res.data ?? [];
|
||||||
|
isLoadingList = false;
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadOrgDetails(int orgId) async {
|
||||||
|
isLoadingDetails = true;
|
||||||
|
selectedOrgDetails = null;
|
||||||
|
update();
|
||||||
|
|
||||||
|
final res = await TransitAdminService.getOrgDetails(orgId);
|
||||||
|
if (res.success) selectedOrgDetails = res.data;
|
||||||
|
isLoadingDetails = false;
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> createOrg({
|
||||||
|
required String type,
|
||||||
|
required String country,
|
||||||
|
required String city,
|
||||||
|
required String nameAr,
|
||||||
|
required String nameEn,
|
||||||
|
required String adminName,
|
||||||
|
required String adminPhone,
|
||||||
|
}) async {
|
||||||
|
isCreating = true;
|
||||||
|
update();
|
||||||
|
|
||||||
|
final res = await TransitAdminService.createOrg(
|
||||||
|
type: type,
|
||||||
|
country: country,
|
||||||
|
city: city,
|
||||||
|
nameAr: nameAr,
|
||||||
|
nameEn: nameEn,
|
||||||
|
adminName: adminName,
|
||||||
|
adminPhone: adminPhone,
|
||||||
|
);
|
||||||
|
|
||||||
|
isCreating = false;
|
||||||
|
update();
|
||||||
|
|
||||||
|
if (res.success) {
|
||||||
|
await fetchOrgs();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Get.snackbar('مواصلاتي', res.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fetchOrgAdmins(int orgId) async {
|
||||||
|
isLoadingAdmins = true;
|
||||||
|
update();
|
||||||
|
final res = await TransitAdminService.listOrgAdmins(orgId);
|
||||||
|
if (res.success) orgAdmins = res.data ?? [];
|
||||||
|
isLoadingAdmins = false;
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> addOrgAdmin({
|
||||||
|
required int orgId,
|
||||||
|
required String name,
|
||||||
|
required String phone,
|
||||||
|
required String role,
|
||||||
|
}) async {
|
||||||
|
isSavingAdmin = true;
|
||||||
|
update();
|
||||||
|
|
||||||
|
final res = await TransitAdminService.addOrgAdmin(
|
||||||
|
orgId: orgId,
|
||||||
|
name: name,
|
||||||
|
phone: phone,
|
||||||
|
role: role,
|
||||||
|
);
|
||||||
|
|
||||||
|
isSavingAdmin = false;
|
||||||
|
update();
|
||||||
|
|
||||||
|
if (res.success) {
|
||||||
|
await fetchOrgAdmins(orgId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Get.snackbar('مواصلاتي', res.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> toggleOrgAdmin(int orgId, int adminId, bool isActive) async {
|
||||||
|
final res = await TransitAdminService.toggleOrgAdmin(adminId: adminId, isActive: isActive);
|
||||||
|
if (res.success) {
|
||||||
|
await fetchOrgAdmins(orgId);
|
||||||
|
} else {
|
||||||
|
Get.snackbar('مواصلاتي', res.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// transit_admin_models.dart — نماذج بيانات مواصلاتي (لوحة إدارة سيرو)
|
||||||
|
|
||||||
|
class TransitOrgSummary {
|
||||||
|
final int id;
|
||||||
|
final String type;
|
||||||
|
final String country;
|
||||||
|
final String city;
|
||||||
|
final String nameAr;
|
||||||
|
final String nameEn;
|
||||||
|
final String? logoUrl;
|
||||||
|
final String contractStatus;
|
||||||
|
final String? trialEndsAt;
|
||||||
|
final int driversCount;
|
||||||
|
final int vehiclesCount;
|
||||||
|
final int activeRoutes;
|
||||||
|
final int activeEnrollments;
|
||||||
|
|
||||||
|
TransitOrgSummary({
|
||||||
|
required this.id,
|
||||||
|
required this.type,
|
||||||
|
required this.country,
|
||||||
|
required this.city,
|
||||||
|
required this.nameAr,
|
||||||
|
required this.nameEn,
|
||||||
|
required this.contractStatus,
|
||||||
|
this.logoUrl,
|
||||||
|
this.trialEndsAt,
|
||||||
|
this.driversCount = 0,
|
||||||
|
this.vehiclesCount = 0,
|
||||||
|
this.activeRoutes = 0,
|
||||||
|
this.activeEnrollments = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory TransitOrgSummary.fromJson(Map<String, dynamic> j) => TransitOrgSummary(
|
||||||
|
id: int.tryParse(j['id'].toString()) ?? 0,
|
||||||
|
type: j['type']?.toString() ?? '',
|
||||||
|
country: j['country']?.toString() ?? '',
|
||||||
|
city: j['city']?.toString() ?? '',
|
||||||
|
nameAr: j['name_ar']?.toString() ?? '',
|
||||||
|
nameEn: j['name_en']?.toString() ?? '',
|
||||||
|
logoUrl: j['logo_url']?.toString(),
|
||||||
|
contractStatus: j['contract_status']?.toString() ?? '',
|
||||||
|
trialEndsAt: j['trial_ends_at']?.toString(),
|
||||||
|
driversCount: int.tryParse(j['drivers_count']?.toString() ?? '0') ?? 0,
|
||||||
|
vehiclesCount: int.tryParse(j['vehicles_count']?.toString() ?? '0') ?? 0,
|
||||||
|
activeRoutes: int.tryParse(j['active_routes']?.toString() ?? '0') ?? 0,
|
||||||
|
activeEnrollments: int.tryParse(j['active_enrollments']?.toString() ?? '0') ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class TransitOrgDetails {
|
||||||
|
final Map<String, dynamic> org;
|
||||||
|
final Map<String, dynamic> counts;
|
||||||
|
final Map<String, dynamic> trips;
|
||||||
|
final List<dynamic> routes;
|
||||||
|
|
||||||
|
TransitOrgDetails({
|
||||||
|
required this.org,
|
||||||
|
required this.counts,
|
||||||
|
required this.trips,
|
||||||
|
required this.routes,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory TransitOrgDetails.fromJson(Map<String, dynamic> j) => TransitOrgDetails(
|
||||||
|
org: Map<String, dynamic>.from(j['org'] ?? {}),
|
||||||
|
counts: Map<String, dynamic>.from(j['counts'] ?? {}),
|
||||||
|
trips: Map<String, dynamic>.from(j['trips'] ?? {}),
|
||||||
|
routes: (j['routes'] is List) ? List<dynamic>.from(j['routes']) : [],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
// transit_admin_service.dart — طبقة الاتصال بـ backend/Admin/transit (لوحة إدارة سيرو)
|
||||||
|
import '../functions/crud.dart';
|
||||||
|
import '../../constant/links.dart';
|
||||||
|
import 'transit_admin_models.dart';
|
||||||
|
|
||||||
|
class TransitApiResult<T> {
|
||||||
|
final bool success;
|
||||||
|
final T? data;
|
||||||
|
final String message;
|
||||||
|
TransitApiResult(this.success, this.data, this.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
class TransitAdminService {
|
||||||
|
static String get _base => '${AppLink.server}/Admin/transit';
|
||||||
|
|
||||||
|
static String _errMsg(dynamic res) {
|
||||||
|
if (res == 'no_internet') return 'تحقق من اتصالك بالإنترنت';
|
||||||
|
if (res == 'token_expired') return 'انتهت الجلسة، حاول مجدداً';
|
||||||
|
if (res is Map && res['message'] is String) return res['message'];
|
||||||
|
return 'حدث خطأ، حاول مجدداً';
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<TransitApiResult<List<TransitOrgSummary>>> listOrgs({
|
||||||
|
String? country,
|
||||||
|
String? type,
|
||||||
|
String? contractStatus,
|
||||||
|
String? search,
|
||||||
|
}) async {
|
||||||
|
final payload = <String, dynamic>{};
|
||||||
|
if (country != null && country.isNotEmpty) payload['country'] = country;
|
||||||
|
if (type != null && type.isNotEmpty) payload['type'] = type;
|
||||||
|
if (contractStatus != null && contractStatus.isNotEmpty) {
|
||||||
|
payload['contract_status'] = contractStatus;
|
||||||
|
}
|
||||||
|
if (search != null && search.isNotEmpty) payload['search'] = search;
|
||||||
|
|
||||||
|
final res = await CRUD().post(link: '$_base/org/list.php', payload: payload);
|
||||||
|
|
||||||
|
if (res is Map && res['status'] == 'success' && res['message'] is Map) {
|
||||||
|
final msg = res['message'] as Map;
|
||||||
|
final list = (msg['orgs'] is List)
|
||||||
|
? (msg['orgs'] as List)
|
||||||
|
.map((o) => TransitOrgSummary.fromJson(Map<String, dynamic>.from(o)))
|
||||||
|
.toList()
|
||||||
|
: <TransitOrgSummary>[];
|
||||||
|
return TransitApiResult(true, list, 'ok');
|
||||||
|
}
|
||||||
|
return TransitApiResult(false, null, _errMsg(res));
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<TransitApiResult<TransitOrgDetails>> getOrgDetails(int orgId) async {
|
||||||
|
final res = await CRUD().post(
|
||||||
|
link: '$_base/org/details.php',
|
||||||
|
payload: {'org_id': orgId.toString()},
|
||||||
|
);
|
||||||
|
if (res is Map && res['status'] == 'success' && res['message'] is Map) {
|
||||||
|
return TransitApiResult(
|
||||||
|
true, TransitOrgDetails.fromJson(Map<String, dynamic>.from(res['message'])), 'ok');
|
||||||
|
}
|
||||||
|
return TransitApiResult(false, null, _errMsg(res));
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<TransitApiResult<Map<String, dynamic>>> createOrg({
|
||||||
|
required String type,
|
||||||
|
required String country,
|
||||||
|
required String city,
|
||||||
|
required String nameAr,
|
||||||
|
required String nameEn,
|
||||||
|
required String adminName,
|
||||||
|
required String adminPhone,
|
||||||
|
String? contactPhone,
|
||||||
|
String? contactEmail,
|
||||||
|
String? website,
|
||||||
|
}) async {
|
||||||
|
final res = await CRUD().post(link: '$_base/org/create.php', payload: {
|
||||||
|
'type': type,
|
||||||
|
'country': country,
|
||||||
|
'city': city,
|
||||||
|
'name_ar': nameAr,
|
||||||
|
'name_en': nameEn,
|
||||||
|
'admin_name': adminName,
|
||||||
|
'admin_phone': adminPhone,
|
||||||
|
if (contactPhone != null) 'contact_phone': contactPhone,
|
||||||
|
if (contactEmail != null) 'contact_email': contactEmail,
|
||||||
|
if (website != null) 'website': website,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res is Map && res['status'] == 'success') {
|
||||||
|
return TransitApiResult(true, Map<String, dynamic>.from(res['message'] ?? {}), 'ok');
|
||||||
|
}
|
||||||
|
return TransitApiResult(false, null, _errMsg(res));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── مشرفو المؤسسة ────────────────────────────────────────────
|
||||||
|
|
||||||
|
static Future<TransitApiResult<List<Map<String, dynamic>>>> listOrgAdmins(
|
||||||
|
int orgId) async {
|
||||||
|
final res = await CRUD().post(
|
||||||
|
link: '$_base/org/admins_list.php',
|
||||||
|
payload: {'org_id': orgId.toString()},
|
||||||
|
);
|
||||||
|
if (res is Map && res['status'] == 'success' && res['message'] is Map) {
|
||||||
|
final msg = res['message'] as Map;
|
||||||
|
final list = (msg['admins'] is List)
|
||||||
|
? (msg['admins'] as List).map((a) => Map<String, dynamic>.from(a)).toList()
|
||||||
|
: <Map<String, dynamic>>[];
|
||||||
|
return TransitApiResult(true, list, 'ok');
|
||||||
|
}
|
||||||
|
return TransitApiResult(false, null, _errMsg(res));
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<TransitApiResult<void>> addOrgAdmin({
|
||||||
|
required int orgId,
|
||||||
|
required String name,
|
||||||
|
required String phone,
|
||||||
|
required String role,
|
||||||
|
}) async {
|
||||||
|
final res = await CRUD().post(link: '$_base/org/admin_add.php', payload: {
|
||||||
|
'org_id': orgId.toString(),
|
||||||
|
'name': name,
|
||||||
|
'phone': phone,
|
||||||
|
'role': role,
|
||||||
|
});
|
||||||
|
if (res is Map && res['status'] == 'success') return TransitApiResult(true, null, 'ok');
|
||||||
|
return TransitApiResult(false, null, _errMsg(res));
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<TransitApiResult<void>> toggleOrgAdmin({
|
||||||
|
required int adminId,
|
||||||
|
required bool isActive,
|
||||||
|
}) async {
|
||||||
|
final res = await CRUD().post(link: '$_base/org/admin_toggle.php', payload: {
|
||||||
|
'admin_id': adminId.toString(),
|
||||||
|
'is_active': isActive ? '1' : '0',
|
||||||
|
});
|
||||||
|
if (res is Map && res['status'] == 'success') return TransitApiResult(true, null, 'ok');
|
||||||
|
return TransitApiResult(false, null, _errMsg(res));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ import 'static/advanced_analytics_page.dart';
|
|||||||
import 'financial/financial_v2_page.dart';
|
import 'financial/financial_v2_page.dart';
|
||||||
import 'security/audit_logs_page.dart';
|
import 'security/audit_logs_page.dart';
|
||||||
import 'analytics/live_analytics_page.dart';
|
import 'analytics/live_analytics_page.dart';
|
||||||
|
import '../transit/org_list_page.dart';
|
||||||
|
|
||||||
class AdminHomePage extends StatefulWidget {
|
class AdminHomePage extends StatefulWidget {
|
||||||
const AdminHomePage({super.key});
|
const AdminHomePage({super.key});
|
||||||
@@ -747,6 +748,13 @@ class _AdminHomePageState extends State<AdminHomePage>
|
|||||||
() => Get.to(() => SiroTrackerScreen())),
|
() => Get.to(() => SiroTrackerScreen())),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
ActionCategory(
|
||||||
|
title: 'مواصلاتي',
|
||||||
|
items: [
|
||||||
|
ActionItem('المؤسسات', Icons.directions_bus_filled_rounded, _accent,
|
||||||
|
() => Get.to(() => const TransitOrgListPage())),
|
||||||
|
],
|
||||||
|
),
|
||||||
ActionCategory(
|
ActionCategory(
|
||||||
title: 'إدارة النظام الجديد',
|
title: 'إدارة النظام الجديد',
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// org_admins_page.dart — متابعة إدارة المؤسسة وإضافة المشرفين (لوحة إدارة سيرو)
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
|
||||||
|
import '../../constant/colors.dart';
|
||||||
|
import '../../controller/transit/transit_admin_controller.dart';
|
||||||
|
|
||||||
|
class TransitOrgAdminsPage extends StatefulWidget {
|
||||||
|
final int orgId;
|
||||||
|
final String orgName;
|
||||||
|
const TransitOrgAdminsPage({super.key, required this.orgId, required this.orgName});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TransitOrgAdminsPage> createState() => _TransitOrgAdminsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TransitOrgAdminsPageState extends State<TransitOrgAdminsPage> {
|
||||||
|
final _name = TextEditingController();
|
||||||
|
final _phone = TextEditingController();
|
||||||
|
String _role = 'transport_manager';
|
||||||
|
|
||||||
|
final _roles = const {
|
||||||
|
'owner': 'مالك',
|
||||||
|
'transport_manager': 'مدير نقل',
|
||||||
|
'dispatcher': 'منسّق',
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
Get.find<TransitAdminController>().fetchOrgAdmins(widget.orgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GetBuilder<TransitAdminController>(
|
||||||
|
builder: (c) => Scaffold(
|
||||||
|
backgroundColor: AppColor.bg,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: AppColor.bg,
|
||||||
|
elevation: 0,
|
||||||
|
title: Text('مشرفو ${widget.orgName}',
|
||||||
|
style: const TextStyle(color: AppColor.textPrimary)),
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
backgroundColor: AppColor.accent,
|
||||||
|
onPressed: () => _showAddAdminSheet(context, c),
|
||||||
|
child: const Icon(Icons.person_add_alt_1, color: Colors.white),
|
||||||
|
),
|
||||||
|
body: c.isLoadingAdmins
|
||||||
|
? const Center(child: CircularProgressIndicator(color: AppColor.accent))
|
||||||
|
: c.orgAdmins.isEmpty
|
||||||
|
? const Center(
|
||||||
|
child: Text('لا يوجد مشرفون بعد', style: TextStyle(color: AppColor.textSecondary)))
|
||||||
|
: ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: c.orgAdmins.length,
|
||||||
|
itemBuilder: (_, i) => _adminCard(c, c.orgAdmins[i]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _adminCard(TransitAdminController c, Map<String, dynamic> admin) {
|
||||||
|
final isActive = admin['is_active'].toString() == '1';
|
||||||
|
return Card(
|
||||||
|
color: AppColor.surface,
|
||||||
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||||
|
child: ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: AppColor.accentSoft,
|
||||||
|
child: Icon(Icons.person, color: isActive ? AppColor.accent : AppColor.textMuted),
|
||||||
|
),
|
||||||
|
title: Text(admin['name']?.toString() ?? '',
|
||||||
|
style: const TextStyle(color: AppColor.textPrimary, fontWeight: FontWeight.bold)),
|
||||||
|
subtitle: Text(
|
||||||
|
'${admin['phone'] ?? ''} · ${_roles[admin['role']] ?? admin['role']}',
|
||||||
|
style: const TextStyle(color: AppColor.textSecondary, fontSize: 12),
|
||||||
|
),
|
||||||
|
trailing: Switch(
|
||||||
|
value: isActive,
|
||||||
|
activeThumbColor: AppColor.success,
|
||||||
|
onChanged: (v) => c.toggleOrgAdmin(widget.orgId, int.parse(admin['id'].toString()), v),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showAddAdminSheet(BuildContext context, TransitAdminController c) {
|
||||||
|
_name.clear();
|
||||||
|
_phone.clear();
|
||||||
|
_role = 'transport_manager';
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: AppColor.surface,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (ctx) => StatefulBuilder(
|
||||||
|
builder: (ctx, setSheetState) => GetBuilder<TransitAdminController>(
|
||||||
|
builder: (c) => Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
top: 20,
|
||||||
|
bottom: MediaQuery.of(ctx).viewInsets.bottom + 20,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('إضافة مشرف جديد',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColor.textPrimary, fontSize: 18, fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _name,
|
||||||
|
style: const TextStyle(color: AppColor.textPrimary),
|
||||||
|
decoration: _inputDecoration('اسم المشرف'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _phone,
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
style: const TextStyle(color: AppColor.textPrimary),
|
||||||
|
decoration: _inputDecoration('رقم الهاتف'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
initialValue: _role,
|
||||||
|
dropdownColor: AppColor.surface,
|
||||||
|
style: const TextStyle(color: AppColor.textPrimary),
|
||||||
|
decoration: _inputDecoration('الصلاحية'),
|
||||||
|
items: _roles.entries
|
||||||
|
.map((e) => DropdownMenuItem(value: e.key, child: Text(e.value)))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (v) => setSheetState(() => _role = v!),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColor.accent,
|
||||||
|
minimumSize: const Size.fromHeight(48),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
onPressed: c.isSavingAdmin
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
if (_name.text.trim().isEmpty || _phone.text.trim().isEmpty) {
|
||||||
|
Get.snackbar('مواصلاتي', 'الرجاء تعبئة كل الحقول');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final ok = await c.addOrgAdmin(
|
||||||
|
orgId: widget.orgId,
|
||||||
|
name: _name.text.trim(),
|
||||||
|
phone: _phone.text.trim(),
|
||||||
|
role: _role,
|
||||||
|
);
|
||||||
|
if (ok && ctx.mounted) Navigator.pop(ctx);
|
||||||
|
},
|
||||||
|
child: c.isSavingAdmin
|
||||||
|
? const SizedBox(
|
||||||
|
height: 20, width: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||||
|
: const Text('إضافة', style: TextStyle(color: Colors.white)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
InputDecoration _inputDecoration(String label) => InputDecoration(
|
||||||
|
labelText: label,
|
||||||
|
labelStyle: const TextStyle(color: AppColor.textSecondary),
|
||||||
|
filled: true,
|
||||||
|
fillColor: AppColor.surfaceElevated,
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
// org_create_page.dart — إضافة مؤسسة جديدة (لوحة إدارة سيرو)
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
|
||||||
|
import '../../constant/colors.dart';
|
||||||
|
import '../../controller/transit/transit_admin_controller.dart';
|
||||||
|
|
||||||
|
class TransitOrgCreatePage extends StatefulWidget {
|
||||||
|
const TransitOrgCreatePage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TransitOrgCreatePage> createState() => _TransitOrgCreatePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TransitOrgCreatePageState extends State<TransitOrgCreatePage> {
|
||||||
|
final _nameAr = TextEditingController();
|
||||||
|
final _nameEn = TextEditingController();
|
||||||
|
final _city = TextEditingController();
|
||||||
|
final _adminName = TextEditingController();
|
||||||
|
final _adminPhone = TextEditingController();
|
||||||
|
|
||||||
|
String _type = 'university';
|
||||||
|
String _country = 'JO';
|
||||||
|
|
||||||
|
final _types = const {
|
||||||
|
'university': 'جامعة',
|
||||||
|
'school': 'مدرسة',
|
||||||
|
'hotel': 'فندق',
|
||||||
|
'company': 'شركة',
|
||||||
|
'transporter': 'ناقل',
|
||||||
|
};
|
||||||
|
final _countries = const {'JO': 'الأردن', 'SY': 'سوريا', 'EG': 'مصر'};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GetBuilder<TransitAdminController>(
|
||||||
|
builder: (c) => Scaffold(
|
||||||
|
backgroundColor: AppColor.bg,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: AppColor.bg,
|
||||||
|
elevation: 0,
|
||||||
|
title: const Text('إضافة مؤسسة', style: TextStyle(color: AppColor.textPrimary)),
|
||||||
|
),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
_dropdown('نوع المؤسسة', _type, _types, (v) => setState(() => _type = v!)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_dropdown('الدولة', _country, _countries, (v) => setState(() => _country = v!)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_field(_nameAr, 'اسم المؤسسة (عربي)'),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_field(_nameEn, 'اسم المؤسسة (إنجليزي)'),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_field(_city, 'المدينة'),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
const Divider(color: AppColor.surfaceElevated),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text('أول مشرف (owner) لهذه المؤسسة',
|
||||||
|
style: TextStyle(color: AppColor.textSecondary)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_field(_adminName, 'اسم المشرف'),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_field(_adminPhone, 'هاتف المشرف', keyboardType: TextInputType.phone),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColor.accent,
|
||||||
|
minimumSize: const Size.fromHeight(50),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
onPressed: c.isCreating ? null : _submit,
|
||||||
|
child: c.isCreating
|
||||||
|
? const SizedBox(
|
||||||
|
height: 20, width: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||||
|
: const Text('إنشاء', style: TextStyle(color: Colors.white)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _field(TextEditingController ctrl, String label, {TextInputType? keyboardType}) {
|
||||||
|
return TextField(
|
||||||
|
controller: ctrl,
|
||||||
|
keyboardType: keyboardType,
|
||||||
|
style: const TextStyle(color: AppColor.textPrimary),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: label,
|
||||||
|
labelStyle: const TextStyle(color: AppColor.textSecondary),
|
||||||
|
filled: true,
|
||||||
|
fillColor: AppColor.surface,
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _dropdown(String label, String value, Map<String, String> options,
|
||||||
|
void Function(String?) onChanged) {
|
||||||
|
return DropdownButtonFormField<String>(
|
||||||
|
initialValue: value,
|
||||||
|
dropdownColor: AppColor.surface,
|
||||||
|
style: const TextStyle(color: AppColor.textPrimary),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: label,
|
||||||
|
labelStyle: const TextStyle(color: AppColor.textSecondary),
|
||||||
|
filled: true,
|
||||||
|
fillColor: AppColor.surface,
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
|
||||||
|
),
|
||||||
|
items: options.entries
|
||||||
|
.map((e) => DropdownMenuItem(value: e.key, child: Text(e.value)))
|
||||||
|
.toList(),
|
||||||
|
onChanged: onChanged,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (_nameAr.text.trim().isEmpty ||
|
||||||
|
_nameEn.text.trim().isEmpty ||
|
||||||
|
_city.text.trim().isEmpty ||
|
||||||
|
_adminName.text.trim().isEmpty ||
|
||||||
|
_adminPhone.text.trim().isEmpty) {
|
||||||
|
Get.snackbar('مواصلاتي', 'الرجاء تعبئة كل الحقول');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final c = Get.find<TransitAdminController>();
|
||||||
|
final ok = await c.createOrg(
|
||||||
|
type: _type,
|
||||||
|
country: _country,
|
||||||
|
city: _city.text.trim(),
|
||||||
|
nameAr: _nameAr.text.trim(),
|
||||||
|
nameEn: _nameEn.text.trim(),
|
||||||
|
adminName: _adminName.text.trim(),
|
||||||
|
adminPhone: _adminPhone.text.trim(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ok && mounted) Get.back(result: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
// org_details_page.dart — تفاصيل وتحليلات مؤسسة (لوحة إدارة سيرو)
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
|
||||||
|
import '../../constant/colors.dart';
|
||||||
|
import '../../controller/transit/transit_admin_controller.dart';
|
||||||
|
import 'org_admins_page.dart';
|
||||||
|
|
||||||
|
class TransitOrgDetailsPage extends StatefulWidget {
|
||||||
|
final int orgId;
|
||||||
|
final String orgName;
|
||||||
|
const TransitOrgDetailsPage({super.key, required this.orgId, required this.orgName});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TransitOrgDetailsPage> createState() => _TransitOrgDetailsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TransitOrgDetailsPageState extends State<TransitOrgDetailsPage> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
Get.find<TransitAdminController>().loadOrgDetails(widget.orgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GetBuilder<TransitAdminController>(
|
||||||
|
builder: (c) => Scaffold(
|
||||||
|
backgroundColor: AppColor.bg,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: AppColor.bg,
|
||||||
|
elevation: 0,
|
||||||
|
title: Text(widget.orgName, style: const TextStyle(color: AppColor.textPrimary)),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'إدارة المشرفين',
|
||||||
|
icon: const Icon(Icons.admin_panel_settings_outlined, color: AppColor.accent),
|
||||||
|
onPressed: () => Get.to(
|
||||||
|
() => TransitOrgAdminsPage(orgId: widget.orgId, orgName: widget.orgName),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: c.isLoadingDetails
|
||||||
|
? const Center(child: CircularProgressIndicator(color: AppColor.accent))
|
||||||
|
: c.selectedOrgDetails == null
|
||||||
|
? const Center(
|
||||||
|
child: Text('تعذّر تحميل البيانات', style: TextStyle(color: AppColor.textSecondary)))
|
||||||
|
: _content(c),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _content(TransitAdminController c) {
|
||||||
|
final d = c.selectedOrgDetails!;
|
||||||
|
final counts = d.counts;
|
||||||
|
final trips = d.trips;
|
||||||
|
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
_sectionTitle('الأسطول'),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_statCard('السائقون', '${counts['drivers_active'] ?? 0}/${counts['drivers_total'] ?? 0}',
|
||||||
|
Icons.badge_outlined),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
_statCard('المركبات', '${counts['vehicles_active'] ?? 0}/${counts['vehicles_total'] ?? 0}',
|
||||||
|
Icons.directions_bus_outlined),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_statCard('الخطوط النشطة', '${counts['routes_active'] ?? 0}', Icons.route_outlined),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
_statCard('عضويات نشطة', '${counts['enrollments_active'] ?? 0}', Icons.people_outline),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if ((int.tryParse(counts['enrollments_pending']?.toString() ?? '0') ?? 0) > 0) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_statCard('طلبات بانتظار الموافقة', '${counts['enrollments_pending']}',
|
||||||
|
Icons.pending_actions_outlined, color: AppColor.warning),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_sectionTitle('الرحلات'),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_statCard('اليوم', '${trips['today'] ?? 0}', Icons.today_outlined),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
_statCard('هذا الأسبوع', '${trips['this_week'] ?? 0}', Icons.date_range_outlined),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_statCard('هذا الشهر', '${trips['this_month'] ?? 0}', Icons.calendar_month_outlined),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
_statCard('ساعات القيادة', '${trips['total_hours_driven'] ?? 0}', Icons.timer_outlined),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_statCard('مكتملة', '${trips['completed'] ?? 0}', Icons.check_circle_outline,
|
||||||
|
color: AppColor.success),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
_statCard('متوسط التأخير', '${trips['avg_delay_minutes'] ?? 0} د', Icons.timelapse,
|
||||||
|
color: AppColor.warning),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_sectionTitle('الخطوط'),
|
||||||
|
...d.routes.map((r) => Card(
|
||||||
|
color: AppColor.surface,
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: ListTile(
|
||||||
|
title: Text(r['name_ar']?.toString() ?? '',
|
||||||
|
style: const TextStyle(color: AppColor.textPrimary)),
|
||||||
|
subtitle: Text(
|
||||||
|
'${r['stops_count'] ?? 0} محطة · ${r['completed_trips'] ?? 0} رحلة مكتملة',
|
||||||
|
style: const TextStyle(color: AppColor.textSecondary, fontSize: 12),
|
||||||
|
),
|
||||||
|
trailing: Text(
|
||||||
|
r['status']?.toString() ?? '',
|
||||||
|
style: TextStyle(
|
||||||
|
color: r['status'] == 'active' ? AppColor.success : AppColor.textSecondary,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _sectionTitle(String text) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 10),
|
||||||
|
child: Text(text,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColor.textPrimary, fontSize: 16, fontWeight: FontWeight.bold)),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _statCard(String label, String value, IconData icon, {Color? color}) {
|
||||||
|
return Expanded(
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColor.surface,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: color ?? AppColor.accent, size: 20),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(value,
|
||||||
|
style: TextStyle(
|
||||||
|
color: color ?? AppColor.textPrimary,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(label, style: const TextStyle(color: AppColor.textSecondary, fontSize: 11)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// org_list_page.dart — قائمة مؤسسات مواصلاتي (لوحة إدارة سيرو)
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
|
||||||
|
import '../../constant/colors.dart';
|
||||||
|
import '../../controller/transit/transit_admin_controller.dart';
|
||||||
|
import '../../controller/transit/transit_admin_models.dart';
|
||||||
|
import 'org_create_page.dart';
|
||||||
|
import 'org_details_page.dart';
|
||||||
|
|
||||||
|
class TransitOrgListPage extends StatefulWidget {
|
||||||
|
const TransitOrgListPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TransitOrgListPage> createState() => _TransitOrgListPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TransitOrgListPageState extends State<TransitOrgListPage> {
|
||||||
|
final _searchCtrl = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
Get.put(TransitAdminController());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GetBuilder<TransitAdminController>(
|
||||||
|
builder: (c) => Scaffold(
|
||||||
|
backgroundColor: AppColor.bg,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: AppColor.bg,
|
||||||
|
elevation: 0,
|
||||||
|
title: const Text('مواصلاتي — المؤسسات', style: TextStyle(color: AppColor.textPrimary)),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.add, color: AppColor.accent),
|
||||||
|
onPressed: () async {
|
||||||
|
final created = await Get.to(() => const TransitOrgCreatePage());
|
||||||
|
if (created == true) c.fetchOrgs();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchCtrl,
|
||||||
|
style: const TextStyle(color: AppColor.textPrimary),
|
||||||
|
onSubmitted: (v) {
|
||||||
|
c.searchQuery = v;
|
||||||
|
c.fetchOrgs();
|
||||||
|
},
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'ابحث عن مؤسسة...',
|
||||||
|
hintStyle: const TextStyle(color: AppColor.textSecondary),
|
||||||
|
prefixIcon: const Icon(Icons.search, color: AppColor.textSecondary),
|
||||||
|
filled: true,
|
||||||
|
fillColor: AppColor.surface,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: c.isLoadingList
|
||||||
|
? const Center(child: CircularProgressIndicator(color: AppColor.accent))
|
||||||
|
: c.orgs.isEmpty
|
||||||
|
? const Center(
|
||||||
|
child: Text('لا توجد مؤسسات', style: TextStyle(color: AppColor.textSecondary)),
|
||||||
|
)
|
||||||
|
: RefreshIndicator(
|
||||||
|
onRefresh: c.fetchOrgs,
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
itemCount: c.orgs.length,
|
||||||
|
itemBuilder: (_, i) => _orgCard(c.orgs[i]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _orgCard(TransitOrgSummary org) {
|
||||||
|
Color statusColor;
|
||||||
|
switch (org.contractStatus) {
|
||||||
|
case 'active':
|
||||||
|
statusColor = AppColor.success;
|
||||||
|
break;
|
||||||
|
case 'trial':
|
||||||
|
statusColor = AppColor.info;
|
||||||
|
break;
|
||||||
|
case 'suspended':
|
||||||
|
statusColor = AppColor.warning;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
statusColor = AppColor.danger;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
color: AppColor.surface,
|
||||||
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||||
|
child: ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
title: Text(org.nameAr,
|
||||||
|
style: const TextStyle(color: AppColor.textPrimary, fontWeight: FontWeight.bold)),
|
||||||
|
subtitle: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: statusColor.withOpacity(0.15),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(org.contractStatus,
|
||||||
|
style: TextStyle(color: statusColor, fontSize: 11)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text('${org.city} · ${org.country}',
|
||||||
|
style: const TextStyle(color: AppColor.textSecondary, fontSize: 12)),
|
||||||
|
const Spacer(),
|
||||||
|
Icon(Icons.directions_bus, size: 14, color: AppColor.textSecondary),
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
Text('${org.vehiclesCount}',
|
||||||
|
style: const TextStyle(color: AppColor.textSecondary, fontSize: 12)),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Icon(Icons.people, size: 14, color: AppColor.textSecondary),
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
Text('${org.activeEnrollments}',
|
||||||
|
style: const TextStyle(color: AppColor.textSecondary, fontSize: 12)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: const Icon(Icons.arrow_forward_ios, size: 14, color: AppColor.textSecondary),
|
||||||
|
onTap: () => Get.to(() => TransitOrgDetailsPage(orgId: org.id, orgName: org.nameAr)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,19 @@ class LocationController extends GetxController with WidgetsBindingObserver {
|
|||||||
bool isSocketConnected = false;
|
bool isSocketConnected = false;
|
||||||
Timer? _socketHeartbeat;
|
Timer? _socketHeartbeat;
|
||||||
|
|
||||||
|
// 🚌 مواصلاتي — وضع الباص: بينما نشط، الموقع يُبَث فقط عبر update_bus_location
|
||||||
|
// ولا يدخل حوض الرحلات العادي (geo:drivers:*)
|
||||||
|
bool isBusMode = false;
|
||||||
|
int? activeBusTripId;
|
||||||
|
int? activeBusRouteId;
|
||||||
|
|
||||||
|
void setBusMode({required bool enabled, int? tripId, int? routeId}) {
|
||||||
|
isBusMode = enabled;
|
||||||
|
activeBusTripId = enabled ? tripId : null;
|
||||||
|
activeBusRouteId = enabled ? routeId : null;
|
||||||
|
Log.print('🚌 Bus mode ${enabled ? "enabled" : "disabled"} (trip: $tripId, route: $routeId)');
|
||||||
|
}
|
||||||
|
|
||||||
StreamSubscription<LocationData>? _locSub;
|
StreamSubscription<LocationData>? _locSub;
|
||||||
StreamSubscription<BatteryState>? _batterySub;
|
StreamSubscription<BatteryState>? _batterySub;
|
||||||
|
|
||||||
@@ -431,7 +444,11 @@ class LocationController extends GetxController with WidgetsBindingObserver {
|
|||||||
// الـ _locSub يرسل update_location عند كل تحرك (كل 5-10 ثوانٍ) تلقائياً.
|
// الـ _locSub يرسل update_location عند كل تحرك (كل 5-10 ثوانٍ) تلقائياً.
|
||||||
// الـ heartbeat يكون مفيداً فقط عندما يتوقف الـ stream (الجهاز ثابت أو أوقف الخدمة).
|
// الـ heartbeat يكون مفيداً فقط عندما يتوقف الـ stream (الجهاز ثابت أو أوقف الخدمة).
|
||||||
if (_locSub != null) return;
|
if (_locSub != null) return;
|
||||||
if (socket != null && isSocketConnected && myLocation.latitude != 0) {
|
if (socket == null || !isSocketConnected || myLocation.latitude == 0) return;
|
||||||
|
|
||||||
|
if (isBusMode) {
|
||||||
|
emitBusLocationToSocket(myLocation, heading, speed);
|
||||||
|
} else {
|
||||||
emitLocationToSocket(myLocation, heading, speed);
|
emitLocationToSocket(myLocation, heading, speed);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -477,6 +494,22 @@ class LocationController extends GetxController with WidgetsBindingObserver {
|
|||||||
socket!.emit('update_location', payload);
|
socket!.emit('update_location', payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🚌 مواصلاتي — بثّ موقع الباص (بديل عن update_location أثناء وضع الباص)
|
||||||
|
void emitBusLocationToSocket(LatLng pos, double head, double spd, {int? currentStopSeq}) {
|
||||||
|
if (activeBusTripId == null || activeBusRouteId == null) return;
|
||||||
|
if (socket == null || !socket!.connected) return;
|
||||||
|
|
||||||
|
socket!.emit('update_bus_location', {
|
||||||
|
'trip_id': activeBusTripId,
|
||||||
|
'route_id': activeBusRouteId,
|
||||||
|
'lat': pos.latitude,
|
||||||
|
'lng': pos.longitude,
|
||||||
|
'heading': head,
|
||||||
|
'speed': spd * 3.6,
|
||||||
|
if (currentStopSeq != null) 'current_stop_seq': currentStopSeq,
|
||||||
|
});
|
||||||
|
}
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
// ====== Tracking Logic ======
|
// ====== Tracking Logic ======
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
@@ -535,6 +568,14 @@ class LocationController extends GetxController with WidgetsBindingObserver {
|
|||||||
_lastPosForDistance = pos;
|
_lastPosForDistance = pos;
|
||||||
|
|
||||||
update();
|
update();
|
||||||
|
|
||||||
|
// 🚌 وضع الباص: بثّ موقع الباص فقط — لا يدخل حوض الرحلات العادي
|
||||||
|
if (isBusMode) {
|
||||||
|
emitBusLocationToSocket(pos, heading, speed);
|
||||||
|
await _saveBehaviorIfMoved(pos, now, currentSpeed: speed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
emitLocationToSocket(pos, heading, speed);
|
emitLocationToSocket(pos, heading, speed);
|
||||||
|
|
||||||
if (Get.isRegistered<HomeCaptainController>()) {
|
if (Get.isRegistered<HomeCaptainController>()) {
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
final Map<String, String> ar_eg = {
|
final Map<String, String> ar_eg = {
|
||||||
|
// ── مواصلاتي (Mawasalati) ──
|
||||||
|
"Mawasalati": "مواصلاتي",
|
||||||
|
"This account is not registered as a bus driver in any institution":
|
||||||
|
"هذا الحساب غير مسجّل كسائق باص في أي مؤسسة",
|
||||||
|
"No trips today": "لا توجد رحلات اليوم",
|
||||||
|
"Stops": "محطات",
|
||||||
|
"Delayed by": "متأخر",
|
||||||
|
"Trip completed": "اكتملت الرحلة",
|
||||||
|
"Report Delay": "الإبلاغ عن تأخير",
|
||||||
|
"Number of minutes": "عدد الدقائق",
|
||||||
|
"Send": "إرسال",
|
||||||
" \${durationController.jsonData1['message'][0]['day'].toString().split('-')[1]}": " \${durationController.jsonData1['message'][0]['day'].toString().split('-')[1]}",
|
" \${durationController.jsonData1['message'][0]['day'].toString().split('-')[1]}": " \${durationController.jsonData1['message'][0]['day'].toString().split('-')[1]}",
|
||||||
" \\\${durationController.jsonData1['message'][0]['day'].toString().split('-')[1]}": " \\\${durationController.jsonData1['message'][0]['day'].toString().split('-')[1]}",
|
" \\\${durationController.jsonData1['message'][0]['day'].toString().split('-')[1]}": " \\\${durationController.jsonData1['message'][0]['day'].toString().split('-')[1]}",
|
||||||
" and acknowledge our Privacy Policy.": "وأوافق على سياسة الخصوصية.",
|
" and acknowledge our Privacy Policy.": "وأوافق على سياسة الخصوصية.",
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
final Map<String, String> ar_jo = {
|
final Map<String, String> ar_jo = {
|
||||||
|
// ── مواصلاتي (Mawasalati) ──
|
||||||
|
"Mawasalati": "مواصلاتي",
|
||||||
|
"This account is not registered as a bus driver in any institution":
|
||||||
|
"هذا الحساب غير مسجّل كسائق باص في أي مؤسسة",
|
||||||
|
"No trips today": "لا توجد رحلات اليوم",
|
||||||
|
"Stops": "محطات",
|
||||||
|
"Delayed by": "متأخر",
|
||||||
|
"Trip completed": "اكتملت الرحلة",
|
||||||
|
"Report Delay": "الإبلاغ عن تأخير",
|
||||||
|
"Number of minutes": "عدد الدقائق",
|
||||||
|
"Send": "إرسال",
|
||||||
" \${durationController.jsonData1['message'][0]['day'].toString().split('-')[1]}": " \${durationController.jsonData1['message'][0]['day'].toString().split('-')[1]}",
|
" \${durationController.jsonData1['message'][0]['day'].toString().split('-')[1]}": " \${durationController.jsonData1['message'][0]['day'].toString().split('-')[1]}",
|
||||||
" \\\${durationController.jsonData1['message'][0]['day'].toString().split('-')[1]}": " \\\${durationController.jsonData1['message'][0]['day'].toString().split('-')[1]}",
|
" \\\${durationController.jsonData1['message'][0]['day'].toString().split('-')[1]}": " \\\${durationController.jsonData1['message'][0]['day'].toString().split('-')[1]}",
|
||||||
" and acknowledge our Privacy Policy.": "وأوافق على سياسة الخصوصية الخاصة بنا.",
|
" and acknowledge our Privacy Policy.": "وأوافق على سياسة الخصوصية الخاصة بنا.",
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
final Map<String, String> ar_sy = {
|
final Map<String, String> ar_sy = {
|
||||||
|
// ── مواصلاتي (Mawasalati) ──
|
||||||
|
"Mawasalati": "مواصلاتي",
|
||||||
|
"This account is not registered as a bus driver in any institution":
|
||||||
|
"هذا الحساب غير مسجّل كسائق باص في أي مؤسسة",
|
||||||
|
"No trips today": "لا توجد رحلات اليوم",
|
||||||
|
"Stops": "محطات",
|
||||||
|
"Delayed by": "متأخر",
|
||||||
|
"Trip completed": "اكتملت الرحلة",
|
||||||
|
"Report Delay": "الإبلاغ عن تأخير",
|
||||||
|
"Number of minutes": "عدد الدقائق",
|
||||||
|
"Send": "إرسال",
|
||||||
" \${durationController.jsonData1['message'][0]['day'].toString().split('-')[1]}": " \${durationController.jsonData1['message'][0]['day'].toString().split('-')[1]}",
|
" \${durationController.jsonData1['message'][0]['day'].toString().split('-')[1]}": " \${durationController.jsonData1['message'][0]['day'].toString().split('-')[1]}",
|
||||||
" \\\${durationController.jsonData1['message'][0]['day'].toString().split('-')[1]}": " \\\${durationController.jsonData1['message'][0]['day'].toString().split('-')[1]}",
|
" \\\${durationController.jsonData1['message'][0]['day'].toString().split('-')[1]}": " \\\${durationController.jsonData1['message'][0]['day'].toString().split('-')[1]}",
|
||||||
" and acknowledge our Privacy Policy.": "وبوافق على سياسة الخصوصية.",
|
" and acknowledge our Privacy Policy.": "وبوافق على سياسة الخصوصية.",
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
// transit_driver_controller.dart — تحكم وضع الباص (جهة السائق)
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:intaleq_maps/intaleq_maps.dart' show LatLng;
|
||||||
|
|
||||||
|
import '../functions/location_controller.dart';
|
||||||
|
import 'transit_driver_models.dart';
|
||||||
|
import 'transit_driver_service.dart';
|
||||||
|
|
||||||
|
class TransitDriverController extends GetxController {
|
||||||
|
bool isCheckingBusDriver = true;
|
||||||
|
bool isBusDriver = false;
|
||||||
|
int? driverTransitId;
|
||||||
|
String orgName = '';
|
||||||
|
|
||||||
|
bool isLoadingTrips = false;
|
||||||
|
List<TransitDriverTrip> todayTrips = [];
|
||||||
|
|
||||||
|
TransitDriverTrip? activeTrip;
|
||||||
|
bool isActionInProgress = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
super.onInit();
|
||||||
|
checkBusDriverStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> checkBusDriverStatus() async {
|
||||||
|
isCheckingBusDriver = true;
|
||||||
|
update();
|
||||||
|
|
||||||
|
final res = await TransitDriverService.checkIsBusDriver();
|
||||||
|
if (res.success && res.data != null) {
|
||||||
|
isBusDriver = res.data!['is_bus_driver'] == true;
|
||||||
|
if (isBusDriver) {
|
||||||
|
driverTransitId = int.tryParse(res.data!['driver_transit_id'].toString());
|
||||||
|
orgName = res.data!['org_name']?.toString() ?? '';
|
||||||
|
await fetchTodayTrips();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isCheckingBusDriver = false;
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fetchTodayTrips() async {
|
||||||
|
if (driverTransitId == null) return;
|
||||||
|
isLoadingTrips = true;
|
||||||
|
update();
|
||||||
|
|
||||||
|
final res = await TransitDriverService.getTodayTrips(driverTransitId!);
|
||||||
|
if (res.success) {
|
||||||
|
todayTrips = res.data ?? [];
|
||||||
|
final started = todayTrips.where((t) => t.status == 'started');
|
||||||
|
activeTrip = started.isNotEmpty ? started.first : null;
|
||||||
|
|
||||||
|
// إن كانت هناك رحلة قيد التشغيل بالفعل (مثلاً بعد إعادة فتح التطبيق)، فعّل وضع الباص
|
||||||
|
if (activeTrip != null && Get.isRegistered<LocationController>()) {
|
||||||
|
Get.find<LocationController>().setBusMode(
|
||||||
|
enabled: true,
|
||||||
|
tripId: activeTrip!.id,
|
||||||
|
routeId: activeTrip!.routeId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isLoadingTrips = false;
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> startTrip(TransitDriverTrip trip) async {
|
||||||
|
if (driverTransitId == null || isActionInProgress) return false;
|
||||||
|
isActionInProgress = true;
|
||||||
|
update();
|
||||||
|
|
||||||
|
LatLng pos = const LatLng(0, 0);
|
||||||
|
if (Get.isRegistered<LocationController>()) {
|
||||||
|
pos = Get.find<LocationController>().myLocation;
|
||||||
|
}
|
||||||
|
|
||||||
|
final res = await TransitDriverService.startTrip(
|
||||||
|
tripId: trip.id,
|
||||||
|
driverTransitId: driverTransitId!,
|
||||||
|
lat: pos.latitude,
|
||||||
|
lng: pos.longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.success) {
|
||||||
|
if (Get.isRegistered<LocationController>()) {
|
||||||
|
Get.find<LocationController>().setBusMode(
|
||||||
|
enabled: true,
|
||||||
|
tripId: trip.id,
|
||||||
|
routeId: trip.routeId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await fetchTodayTrips();
|
||||||
|
} else {
|
||||||
|
Get.snackbar('مواصلاتي', res.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
isActionInProgress = false;
|
||||||
|
update();
|
||||||
|
return res.success;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> endTrip(TransitDriverTrip trip) async {
|
||||||
|
if (driverTransitId == null || isActionInProgress) return false;
|
||||||
|
isActionInProgress = true;
|
||||||
|
update();
|
||||||
|
|
||||||
|
final res = await TransitDriverService.endTrip(
|
||||||
|
tripId: trip.id,
|
||||||
|
driverTransitId: driverTransitId!,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.success) {
|
||||||
|
if (Get.isRegistered<LocationController>()) {
|
||||||
|
Get.find<LocationController>().setBusMode(enabled: false);
|
||||||
|
}
|
||||||
|
await fetchTodayTrips();
|
||||||
|
} else {
|
||||||
|
Get.snackbar('مواصلاتي', res.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
isActionInProgress = false;
|
||||||
|
update();
|
||||||
|
return res.success;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> reportDelay(TransitDriverTrip trip, int minutes, {String? reason}) async {
|
||||||
|
if (driverTransitId == null) return false;
|
||||||
|
final res = await TransitDriverService.reportDelay(
|
||||||
|
tripId: trip.id,
|
||||||
|
driverTransitId: driverTransitId!,
|
||||||
|
delayMinutes: minutes,
|
||||||
|
reason: reason,
|
||||||
|
);
|
||||||
|
if (!res.success) Get.snackbar('مواصلاتي', res.message);
|
||||||
|
return res.success;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// transit_driver_models.dart — نماذج بيانات مواصلاتي (جهة سائق الباص)
|
||||||
|
|
||||||
|
class TransitStopInfo {
|
||||||
|
final int sequence;
|
||||||
|
final String nameAr;
|
||||||
|
final double lat;
|
||||||
|
final double lng;
|
||||||
|
final int? etaOffsetMin;
|
||||||
|
|
||||||
|
TransitStopInfo({
|
||||||
|
required this.sequence,
|
||||||
|
required this.nameAr,
|
||||||
|
required this.lat,
|
||||||
|
required this.lng,
|
||||||
|
this.etaOffsetMin,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory TransitStopInfo.fromJson(Map<String, dynamic> j) => TransitStopInfo(
|
||||||
|
sequence: int.tryParse(j['sequence'].toString()) ?? 0,
|
||||||
|
nameAr: j['name_ar']?.toString() ?? '',
|
||||||
|
lat: double.tryParse(j['latitude']?.toString() ?? '0') ?? 0,
|
||||||
|
lng: double.tryParse(j['longitude']?.toString() ?? '0') ?? 0,
|
||||||
|
etaOffsetMin: j['eta_offset_min'] == null
|
||||||
|
? null
|
||||||
|
: int.tryParse(j['eta_offset_min'].toString()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class TransitDriverTrip {
|
||||||
|
final int id;
|
||||||
|
final int routeId;
|
||||||
|
final String status; // scheduled | started | completed | cancelled | no_show
|
||||||
|
final int delayMinutes;
|
||||||
|
final int? currentStopSeq;
|
||||||
|
final String routeName;
|
||||||
|
final String? departureTime;
|
||||||
|
final String? vehiclePlate;
|
||||||
|
final int? capacity;
|
||||||
|
final List<TransitStopInfo> stops;
|
||||||
|
|
||||||
|
TransitDriverTrip({
|
||||||
|
required this.id,
|
||||||
|
required this.routeId,
|
||||||
|
required this.status,
|
||||||
|
required this.delayMinutes,
|
||||||
|
required this.routeName,
|
||||||
|
this.currentStopSeq,
|
||||||
|
this.departureTime,
|
||||||
|
this.vehiclePlate,
|
||||||
|
this.capacity,
|
||||||
|
this.stops = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
factory TransitDriverTrip.fromJson(Map<String, dynamic> j) => TransitDriverTrip(
|
||||||
|
id: int.tryParse(j['id'].toString()) ?? 0,
|
||||||
|
routeId: int.tryParse(j['route_id']?.toString() ?? '0') ?? 0,
|
||||||
|
status: j['status']?.toString() ?? '',
|
||||||
|
delayMinutes: int.tryParse(j['delay_minutes']?.toString() ?? '0') ?? 0,
|
||||||
|
currentStopSeq: j['current_stop_seq'] == null
|
||||||
|
? null
|
||||||
|
: int.tryParse(j['current_stop_seq'].toString()),
|
||||||
|
routeName: j['route_name']?.toString() ?? '',
|
||||||
|
departureTime: j['departure_time']?.toString(),
|
||||||
|
vehiclePlate: j['vehicle_plate']?.toString(),
|
||||||
|
capacity: j['capacity'] == null ? null : int.tryParse(j['capacity'].toString()),
|
||||||
|
stops: (j['stops'] is List)
|
||||||
|
? (j['stops'] as List)
|
||||||
|
.map((s) => TransitStopInfo.fromJson(Map<String, dynamic>.from(s)))
|
||||||
|
.toList()
|
||||||
|
: const [],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// transit_driver_service.dart — طبقة الاتصال بـ backend/transit (جهة سائق الباص)
|
||||||
|
import '../functions/crud.dart';
|
||||||
|
import '../../constant/links.dart';
|
||||||
|
import 'transit_driver_models.dart';
|
||||||
|
|
||||||
|
class TransitApiResult<T> {
|
||||||
|
final bool success;
|
||||||
|
final T? data;
|
||||||
|
final String message;
|
||||||
|
TransitApiResult(this.success, this.data, this.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
class TransitDriverService {
|
||||||
|
static String get _base => '${AppLink.server}/transit';
|
||||||
|
|
||||||
|
static String _errMsg(dynamic res) {
|
||||||
|
if (res == 'no_internet') return 'تحقق من اتصالك بالإنترنت';
|
||||||
|
if (res == 'token_expired') return 'انتهت الجلسة، حاول مجدداً';
|
||||||
|
if (res is Map && res['message'] is String) return res['message'];
|
||||||
|
return 'حدث خطأ، حاول مجدداً';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// هل هذا الحساب سائق باص، وما معرّفه في مواصلاتي؟
|
||||||
|
static Future<TransitApiResult<Map<String, dynamic>>> checkIsBusDriver() async {
|
||||||
|
final res = await CRUD().post(link: '$_base/driver/me.php');
|
||||||
|
if (res is Map && res['status'] == 'success' && res['message'] is Map) {
|
||||||
|
return TransitApiResult(true, Map<String, dynamic>.from(res['message']), 'ok');
|
||||||
|
}
|
||||||
|
return TransitApiResult(false, null, _errMsg(res));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// رحلات اليوم (تُنشأ تلقائياً من الجداول عند أول استدعاء)
|
||||||
|
static Future<TransitApiResult<List<TransitDriverTrip>>> getTodayTrips(
|
||||||
|
int driverTransitId) async {
|
||||||
|
final res = await CRUD().post(
|
||||||
|
link: '$_base/trip/today.php',
|
||||||
|
payload: {'driver_transit_id': driverTransitId.toString()},
|
||||||
|
);
|
||||||
|
if (res is Map && res['status'] == 'success' && res['message'] is Map) {
|
||||||
|
final msg = res['message'] as Map;
|
||||||
|
final list = (msg['trips'] is List)
|
||||||
|
? (msg['trips'] as List)
|
||||||
|
.map((t) => TransitDriverTrip.fromJson(Map<String, dynamic>.from(t)))
|
||||||
|
.toList()
|
||||||
|
: <TransitDriverTrip>[];
|
||||||
|
return TransitApiResult(true, list, 'ok');
|
||||||
|
}
|
||||||
|
return TransitApiResult(false, null, _errMsg(res));
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<TransitApiResult<Map<String, dynamic>>> startTrip({
|
||||||
|
required int tripId,
|
||||||
|
required int driverTransitId,
|
||||||
|
required double lat,
|
||||||
|
required double lng,
|
||||||
|
}) async {
|
||||||
|
final res = await CRUD().post(
|
||||||
|
link: '$_base/trip/start.php',
|
||||||
|
payload: {
|
||||||
|
'trip_id': tripId.toString(),
|
||||||
|
'driver_transit_id': driverTransitId.toString(),
|
||||||
|
'lat': lat.toString(),
|
||||||
|
'lng': lng.toString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (res is Map && res['status'] == 'success') {
|
||||||
|
return TransitApiResult(true, Map<String, dynamic>.from(res['message'] ?? {}), 'ok');
|
||||||
|
}
|
||||||
|
return TransitApiResult(false, null, _errMsg(res));
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<TransitApiResult<Map<String, dynamic>>> endTrip({
|
||||||
|
required int tripId,
|
||||||
|
required int driverTransitId,
|
||||||
|
}) async {
|
||||||
|
final res = await CRUD().post(
|
||||||
|
link: '$_base/trip/end.php',
|
||||||
|
payload: {
|
||||||
|
'trip_id': tripId.toString(),
|
||||||
|
'driver_transit_id': driverTransitId.toString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (res is Map && res['status'] == 'success') {
|
||||||
|
return TransitApiResult(true, Map<String, dynamic>.from(res['message'] ?? {}), 'ok');
|
||||||
|
}
|
||||||
|
return TransitApiResult(false, null, _errMsg(res));
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<TransitApiResult<void>> reportDelay({
|
||||||
|
required int tripId,
|
||||||
|
required int driverTransitId,
|
||||||
|
required int delayMinutes,
|
||||||
|
String? reason,
|
||||||
|
}) async {
|
||||||
|
final res = await CRUD().post(
|
||||||
|
link: '$_base/trip/delay.php',
|
||||||
|
payload: {
|
||||||
|
'trip_id': tripId.toString(),
|
||||||
|
'driver_transit_id': driverTransitId.toString(),
|
||||||
|
'delay_minutes': delayMinutes.toString(),
|
||||||
|
if (reason != null) 'reason': reason,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (res is Map && res['status'] == 'success') {
|
||||||
|
return TransitApiResult(true, null, 'ok');
|
||||||
|
}
|
||||||
|
return TransitApiResult(false, null, _errMsg(res));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ import '../../../../constant/colors.dart';
|
|||||||
import '../About Us/video_page.dart';
|
import '../About Us/video_page.dart';
|
||||||
import '../assurance_health_page.dart';
|
import '../assurance_health_page.dart';
|
||||||
import '../maintain_center_page.dart';
|
import '../maintain_center_page.dart';
|
||||||
|
import '../../../transit/transit_driver_home_page.dart';
|
||||||
|
|
||||||
// 1. إنشاء Class لتعريف بيانات كل عنصر في القائمة
|
// 1. إنشاء Class لتعريف بيانات كل عنصر في القائمة
|
||||||
class DrawerItem {
|
class DrawerItem {
|
||||||
@@ -55,6 +56,11 @@ class AppDrawer extends StatelessWidget {
|
|||||||
|
|
||||||
// 2. تعريف بيانات القائمة بشكل مركزي ومنظم
|
// 2. تعريف بيانات القائمة بشكل مركزي ومنظم
|
||||||
final List<DrawerItem> drawerItems = [
|
final List<DrawerItem> drawerItems = [
|
||||||
|
DrawerItem(
|
||||||
|
title: 'Mawasalati'.tr,
|
||||||
|
icon: Icons.directions_bus_filled_rounded,
|
||||||
|
color: Colors.teal,
|
||||||
|
onTap: () => Get.to(() => const TransitDriverHomePage())),
|
||||||
DrawerItem(
|
DrawerItem(
|
||||||
title: 'Balance'.tr,
|
title: 'Balance'.tr,
|
||||||
icon: Icons.account_balance_wallet,
|
icon: Icons.account_balance_wallet,
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
// transit_driver_home_page.dart — رحلات اليوم لسائق الباص (مواصلاتي)
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
|
||||||
|
import '../../constant/colors.dart';
|
||||||
|
import '../../constant/style.dart';
|
||||||
|
import '../../controller/transit/transit_driver_controller.dart';
|
||||||
|
import '../../controller/transit/transit_driver_models.dart';
|
||||||
|
import '../widgets/elevated_btn.dart';
|
||||||
|
import '../widgets/my_scafold.dart';
|
||||||
|
|
||||||
|
class TransitDriverHomePage extends StatelessWidget {
|
||||||
|
const TransitDriverHomePage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
Get.put(TransitDriverController());
|
||||||
|
|
||||||
|
return GetBuilder<TransitDriverController>(
|
||||||
|
builder: (c) {
|
||||||
|
if (c.isCheckingBusDriver) {
|
||||||
|
return MyScafolld(
|
||||||
|
title: 'Mawasalati'.tr,
|
||||||
|
isleading: true,
|
||||||
|
body: const [Expanded(child: Center(child: CircularProgressIndicator()))],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!c.isBusDriver) {
|
||||||
|
return MyScafolld(
|
||||||
|
title: 'Mawasalati'.tr,
|
||||||
|
isleading: true,
|
||||||
|
body: [
|
||||||
|
Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Text(
|
||||||
|
'This account is not registered as a bus driver in any institution'.tr,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: AppStyle.title,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return MyScafolld(
|
||||||
|
title: c.orgName,
|
||||||
|
isleading: true,
|
||||||
|
body: [
|
||||||
|
Expanded(
|
||||||
|
child: RefreshIndicator(
|
||||||
|
onRefresh: c.fetchTodayTrips,
|
||||||
|
child: c.isLoadingTrips
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: c.todayTrips.isEmpty
|
||||||
|
? ListView(
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 80),
|
||||||
|
Center(
|
||||||
|
child: Text('No trips today'.tr, style: AppStyle.title),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: c.todayTrips.length,
|
||||||
|
itemBuilder: (_, i) => _tripCard(c, c.todayTrips[i]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _tripCard(TransitDriverController c, TransitDriverTrip trip) {
|
||||||
|
final isStarted = trip.status == 'started';
|
||||||
|
final isCompleted = trip.status == 'completed';
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 14),
|
||||||
|
color: AppColor.cardColor,
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
side: BorderSide(color: isStarted ? AppColor.greenColor : AppColor.borderColor,
|
||||||
|
width: isStarted ? 1.5 : 1),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.directions_bus, color: AppColor.accentColor),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(trip.routeName,
|
||||||
|
style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
if (trip.departureTime != null)
|
||||||
|
Text(trip.departureTime!, style: AppStyle.subtitle),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text('${trip.stops.length} ${'Stops'.tr}', style: AppStyle.subtitle),
|
||||||
|
if (trip.delayMinutes > 0)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: Text('${'Delayed by'.tr} ${trip.delayMinutes} ${'min'.tr}',
|
||||||
|
style: AppStyle.subtitle.copyWith(color: AppColor.yellowColor)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (!isCompleted)
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: MyElevatedButton(
|
||||||
|
title: isStarted ? 'End Trip'.tr : 'Start Trip'.tr,
|
||||||
|
kolor: isStarted ? AppColor.redColor : AppColor.greenColor,
|
||||||
|
onPressed: c.isActionInProgress
|
||||||
|
? () {}
|
||||||
|
: () => isStarted ? c.endTrip(trip) : c.startTrip(trip),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isStarted) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.report_gmailerrorred, color: AppColor.yellowColor),
|
||||||
|
onPressed: () => _showDelayDialog(c, trip),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Text('Trip completed'.tr,
|
||||||
|
style: AppStyle.subtitle.copyWith(color: AppColor.greenColor)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showDelayDialog(TransitDriverController c, TransitDriverTrip trip) {
|
||||||
|
final ctrl = TextEditingController(text: '5');
|
||||||
|
Get.defaultDialog(
|
||||||
|
title: 'Report Delay'.tr,
|
||||||
|
content: Column(
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: ctrl,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: InputDecoration(labelText: 'Number of minutes'.tr),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
textConfirm: 'Send'.tr,
|
||||||
|
textCancel: 'Cancel'.tr,
|
||||||
|
onConfirm: () {
|
||||||
|
final minutes = int.tryParse(ctrl.text.trim()) ?? 5;
|
||||||
|
c.reportDelay(trip, minutes);
|
||||||
|
Get.back();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,10 @@ class MapSocketController extends GetxController {
|
|||||||
int _socketLocationUpdatesCount = 0;
|
int _socketLocationUpdatesCount = 0;
|
||||||
Timer? _watchdogTimer;
|
Timer? _watchdogTimer;
|
||||||
|
|
||||||
|
// 🚌 مواصلاتي — الخط الحالي المشترَك فيه + مستمع تحديث موقع الباص
|
||||||
|
int? _subscribedTransitRouteId;
|
||||||
|
void Function(Map<String, dynamic> data)? onBusLocationUpdate;
|
||||||
|
|
||||||
DateTime? get lastDriverLocationTime => _lastSocketLocationTime;
|
DateTime? get lastDriverLocationTime => _lastSocketLocationTime;
|
||||||
int get socketLocationUpdatesCount => _socketLocationUpdatesCount;
|
int get socketLocationUpdatesCount => _socketLocationUpdatesCount;
|
||||||
|
|
||||||
@@ -61,6 +65,10 @@ class MapSocketController extends GetxController {
|
|||||||
});
|
});
|
||||||
Log.print("📡 Re-subscribed to driver location after connect");
|
Log.print("📡 Re-subscribed to driver location after connect");
|
||||||
}
|
}
|
||||||
|
if (_subscribedTransitRouteId != null) {
|
||||||
|
socket.emit('subscribe_transit_route', {'route_id': _subscribedTransitRouteId});
|
||||||
|
Log.print("🚌 Re-subscribed to transit route after connect");
|
||||||
|
}
|
||||||
update();
|
update();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,6 +97,10 @@ class MapSocketController extends GetxController {
|
|||||||
});
|
});
|
||||||
Log.print("📡 Re-subscribed to driver location after reconnect");
|
Log.print("📡 Re-subscribed to driver location after reconnect");
|
||||||
}
|
}
|
||||||
|
if (_subscribedTransitRouteId != null) {
|
||||||
|
socket.emit('subscribe_transit_route', {'route_id': _subscribedTransitRouteId});
|
||||||
|
Log.print("🚌 Re-subscribed to transit route after reconnect");
|
||||||
|
}
|
||||||
|
|
||||||
if (rideLifecycle.isActiveRideState()) {
|
if (rideLifecycle.isActiveRideState()) {
|
||||||
Log.print("✅ Socket back online — stopping Fast Polling Fallback");
|
Log.print("✅ Socket back online — stopping Fast Polling Fallback");
|
||||||
@@ -131,6 +143,39 @@ class MapSocketController extends GetxController {
|
|||||||
socket.on('driver_location_update', (data) {
|
socket.on('driver_location_update', (data) {
|
||||||
handleDriverLocationUpdate(data);
|
handleDriverLocationUpdate(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🚌 مواصلاتي — بثّ موقع الباص الحي لخط مشترَك فيه
|
||||||
|
socket.on('bus_location_update', (data) {
|
||||||
|
if (data == null) return;
|
||||||
|
try {
|
||||||
|
final map = Map<String, dynamic>.from(data as Map);
|
||||||
|
onBusLocationUpdate?.call(map);
|
||||||
|
} catch (e) {
|
||||||
|
Log.print('Error parsing bus_location_update: $e');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── مواصلاتي: اشتراك/إلغاء اشتراك ببثّ موقع خط ─────────────
|
||||||
|
// يُستدعى عند فتح/إغلاق شاشة تتبع الباص الحي. يضمن السوكيت متصلاً أولاً.
|
||||||
|
void subscribeToTransitRoute(int routeId) {
|
||||||
|
_subscribedTransitRouteId = routeId;
|
||||||
|
if (!isSocketConnected) {
|
||||||
|
initConnectionWithSocket();
|
||||||
|
// سيُعاد الاشتراك تلقائياً من onConnect إن أضفنا ذلك، لكن نحاول فوراً أيضاً
|
||||||
|
}
|
||||||
|
if (socket.connected) {
|
||||||
|
socket.emit('subscribe_transit_route', {'route_id': routeId});
|
||||||
|
Log.print('🚌 Subscribed to transit route #$routeId');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void unsubscribeFromTransitRoute(int routeId) {
|
||||||
|
if (_subscribedTransitRouteId == routeId) _subscribedTransitRouteId = null;
|
||||||
|
if (isSocketConnected && socket.connected) {
|
||||||
|
socket.emit('unsubscribe_transit_route', {'route_id': routeId});
|
||||||
|
Log.print('🚌 Unsubscribed from transit route #$routeId');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _startHeartbeat() {
|
void _startHeartbeat() {
|
||||||
|
|||||||
@@ -1,4 +1,26 @@
|
|||||||
final Map<String, String> ar_eg = {
|
final Map<String, String> ar_eg = {
|
||||||
|
// ── مواصلاتي (Mawasalati) ──
|
||||||
|
"Mawasalati": "مواصلاتي",
|
||||||
|
"My Memberships": "عضوياتي",
|
||||||
|
"Not enrolled in any institution yet": "لست مشتركاً في أي مؤسسة بعد",
|
||||||
|
"Activate your membership in your university or institution to track your bus live":
|
||||||
|
"فعّل عضويتك في جامعتك أو مؤسستك لمتابعة الباص الخاص بك حياً",
|
||||||
|
"Join a new institution": "انضمام لمؤسسة جديدة",
|
||||||
|
"University, school, or hotel": "جامعة، مدرسة، أو فندق",
|
||||||
|
"Active": "نشطة",
|
||||||
|
"Pending Review": "قيد المراجعة",
|
||||||
|
"Suspended": "موقوفة",
|
||||||
|
"Choose your institution": "اختر مؤسستك",
|
||||||
|
"Search for a university or institution...": "ابحث عن جامعة أو مؤسسة...",
|
||||||
|
"No results": "لا توجد نتائج",
|
||||||
|
"Enter your student/employee ID to activate membership":
|
||||||
|
"أدخل رقمك الجامعي/الوظيفي لتفعيل العضوية",
|
||||||
|
"Student ID": "الرقم الجامعي",
|
||||||
|
"Activate": "تفعيل",
|
||||||
|
"No active routes currently": "لا توجد خطوط نشطة حالياً",
|
||||||
|
"Stops": "محطات",
|
||||||
|
"No bus running on this route right now": "لا يوجد باص يعمل على هذا الخط الآن",
|
||||||
|
"Bus is delayed": "الباص متأخر",
|
||||||
" ')[0]).toString()}.\\n\${' I am using": " ')[0]).toString()}.\\n\${' I am using",
|
" ')[0]).toString()}.\\n\${' I am using": " ')[0]).toString()}.\\n\${' I am using",
|
||||||
" I am currently located at ": " أنا حالياً في ",
|
" I am currently located at ": " أنا حالياً في ",
|
||||||
" I am using": " أنا استخدم",
|
" I am using": " أنا استخدم",
|
||||||
|
|||||||
@@ -1,4 +1,26 @@
|
|||||||
final Map<String, String> ar_jo = {
|
final Map<String, String> ar_jo = {
|
||||||
|
// ── مواصلاتي (Mawasalati) ──
|
||||||
|
"Mawasalati": "مواصلاتي",
|
||||||
|
"My Memberships": "عضوياتي",
|
||||||
|
"Not enrolled in any institution yet": "لست مشتركاً في أي مؤسسة بعد",
|
||||||
|
"Activate your membership in your university or institution to track your bus live":
|
||||||
|
"فعّل عضويتك في جامعتك أو مؤسستك لمتابعة الباص الخاص بك حياً",
|
||||||
|
"Join a new institution": "انضمام لمؤسسة جديدة",
|
||||||
|
"University, school, or hotel": "جامعة، مدرسة، أو فندق",
|
||||||
|
"Active": "نشطة",
|
||||||
|
"Pending Review": "قيد المراجعة",
|
||||||
|
"Suspended": "موقوفة",
|
||||||
|
"Choose your institution": "اختر مؤسستك",
|
||||||
|
"Search for a university or institution...": "ابحث عن جامعة أو مؤسسة...",
|
||||||
|
"No results": "لا توجد نتائج",
|
||||||
|
"Enter your student/employee ID to activate membership":
|
||||||
|
"أدخل رقمك الجامعي/الوظيفي لتفعيل العضوية",
|
||||||
|
"Student ID": "الرقم الجامعي",
|
||||||
|
"Activate": "تفعيل",
|
||||||
|
"No active routes currently": "لا توجد خطوط نشطة حالياً",
|
||||||
|
"Stops": "محطات",
|
||||||
|
"No bus running on this route right now": "لا يوجد باص يعمل على هذا الخط الآن",
|
||||||
|
"Bus is delayed": "الباص متأخر",
|
||||||
" ')[0]).toString()}.\\n\${' I am using": " ')[0]).toString()}.\\n\${' I am using",
|
" ')[0]).toString()}.\\n\${' I am using": " ')[0]).toString()}.\\n\${' I am using",
|
||||||
" I am currently located at ": " أنا حالياً في ",
|
" I am currently located at ": " أنا حالياً في ",
|
||||||
" I am using": " أنا استخدم",
|
" I am using": " أنا استخدم",
|
||||||
|
|||||||
@@ -1,4 +1,26 @@
|
|||||||
final Map<String, String> ar_sy = {
|
final Map<String, String> ar_sy = {
|
||||||
|
// ── مواصلاتي (Mawasalati) ──
|
||||||
|
"Mawasalati": "مواصلاتي",
|
||||||
|
"My Memberships": "عضوياتي",
|
||||||
|
"Not enrolled in any institution yet": "لست مشتركاً في أي مؤسسة بعد",
|
||||||
|
"Activate your membership in your university or institution to track your bus live":
|
||||||
|
"فعّل عضويتك في جامعتك أو مؤسستك لمتابعة الباص الخاص بك حياً",
|
||||||
|
"Join a new institution": "انضمام لمؤسسة جديدة",
|
||||||
|
"University, school, or hotel": "جامعة، مدرسة، أو فندق",
|
||||||
|
"Active": "نشطة",
|
||||||
|
"Pending Review": "قيد المراجعة",
|
||||||
|
"Suspended": "موقوفة",
|
||||||
|
"Choose your institution": "اختر مؤسستك",
|
||||||
|
"Search for a university or institution...": "ابحث عن جامعة أو مؤسسة...",
|
||||||
|
"No results": "لا توجد نتائج",
|
||||||
|
"Enter your student/employee ID to activate membership":
|
||||||
|
"أدخل رقمك الجامعي/الوظيفي لتفعيل العضوية",
|
||||||
|
"Student ID": "الرقم الجامعي",
|
||||||
|
"Activate": "تفعيل",
|
||||||
|
"No active routes currently": "لا توجد خطوط نشطة حالياً",
|
||||||
|
"Stops": "محطات",
|
||||||
|
"No bus running on this route right now": "لا يوجد باص يعمل على هذا الخط الآن",
|
||||||
|
"Bus is delayed": "الباص متأخر",
|
||||||
" ')[0]).toString()}.\\n\${' I am using": " ')[0]).toString()}.\\n\${' I am using",
|
" ')[0]).toString()}.\\n\${' I am using": " ')[0]).toString()}.\\n\${' I am using",
|
||||||
" I am currently located at ": " أنا حالياً في ",
|
" I am currently located at ": " أنا حالياً في ",
|
||||||
" I am using": " أنا استخدم",
|
" I am using": " أنا استخدم",
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
// transit_controller.dart — تحكم شاشات مواصلاتي (جهة الراكب)
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import '../home/map/map_socket_controller.dart';
|
||||||
|
import '../../print.dart';
|
||||||
|
import 'transit_models.dart';
|
||||||
|
import 'transit_service.dart';
|
||||||
|
|
||||||
|
class TransitController extends GetxController {
|
||||||
|
bool isLoadingEnrollments = false;
|
||||||
|
List<TransitEnrollment> myEnrollments = [];
|
||||||
|
|
||||||
|
bool isLoadingOrgs = false;
|
||||||
|
List<TransitOrg> browsableOrgs = [];
|
||||||
|
|
||||||
|
bool isLoadingRoutes = false;
|
||||||
|
List<TransitRouteSummary> currentOrgRoutes = [];
|
||||||
|
int? currentOrgId;
|
||||||
|
|
||||||
|
// ── تتبع الباص الحي ──────────────────────────────────────────
|
||||||
|
int? liveRouteId;
|
||||||
|
int? liveTripId;
|
||||||
|
Map<String, dynamic>? liveTripData;
|
||||||
|
BusPosition? liveBusPosition;
|
||||||
|
String liveError = '';
|
||||||
|
bool isLoadingLiveTrip = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
super.onInit();
|
||||||
|
fetchMyEnrollments();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fetchMyEnrollments() async {
|
||||||
|
isLoadingEnrollments = true;
|
||||||
|
update();
|
||||||
|
final res = await TransitService.getMyEnrollments();
|
||||||
|
if (res.success) myEnrollments = res.data ?? [];
|
||||||
|
isLoadingEnrollments = false;
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> browseOrgs({String? search}) async {
|
||||||
|
isLoadingOrgs = true;
|
||||||
|
update();
|
||||||
|
final res = await TransitService.browseOrgs(search: search);
|
||||||
|
if (res.success) browsableOrgs = res.data ?? [];
|
||||||
|
isLoadingOrgs = false;
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> activateEnrollment(int orgId, String studentId) async {
|
||||||
|
final res = await TransitService.activateEnrollment(
|
||||||
|
orgId: orgId,
|
||||||
|
studentId: studentId,
|
||||||
|
);
|
||||||
|
if (res.success) {
|
||||||
|
await fetchMyEnrollments();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Get.snackbar('مواصلاتي', res.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> openOrgRoutes(int orgId) async {
|
||||||
|
currentOrgId = orgId;
|
||||||
|
isLoadingRoutes = true;
|
||||||
|
currentOrgRoutes = [];
|
||||||
|
update();
|
||||||
|
|
||||||
|
final res = await TransitService.getRoutesForOrg(orgId);
|
||||||
|
if (res.success) {
|
||||||
|
currentOrgRoutes = res.data ?? [];
|
||||||
|
} else {
|
||||||
|
Get.snackbar('مواصلاتي', res.message);
|
||||||
|
}
|
||||||
|
isLoadingRoutes = false;
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── الخط الحي: تحميل أولي + اشتراك سوكيت ────────────────────
|
||||||
|
Future<void> openLiveRoute(int routeId) async {
|
||||||
|
liveRouteId = routeId;
|
||||||
|
liveTripData = null;
|
||||||
|
liveBusPosition = null;
|
||||||
|
liveError = '';
|
||||||
|
isLoadingLiveTrip = true;
|
||||||
|
update();
|
||||||
|
|
||||||
|
final res = await TransitService.getLiveTrip(routeId: routeId);
|
||||||
|
if (res.success && res.data != null) {
|
||||||
|
liveTripData = res.data;
|
||||||
|
liveTripId = int.tryParse(liveTripData?['trip']?['id']?.toString() ?? '');
|
||||||
|
final pos = liveTripData?['bus_position'];
|
||||||
|
if (pos is Map) liveBusPosition = BusPosition.fromJson(pos);
|
||||||
|
} else {
|
||||||
|
liveError = res.message;
|
||||||
|
}
|
||||||
|
isLoadingLiveTrip = false;
|
||||||
|
update();
|
||||||
|
|
||||||
|
if (Get.isRegistered<MapSocketController>()) {
|
||||||
|
final ms = Get.find<MapSocketController>();
|
||||||
|
ms.onBusLocationUpdate = _handleLiveBusUpdate;
|
||||||
|
ms.subscribeToTransitRoute(routeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleLiveBusUpdate(Map<String, dynamic> data) {
|
||||||
|
final incomingRouteId = int.tryParse(data['route_id']?.toString() ?? '');
|
||||||
|
if (incomingRouteId == null || incomingRouteId != liveRouteId) return;
|
||||||
|
|
||||||
|
liveBusPosition = BusPosition(
|
||||||
|
lat: double.tryParse(data['latitude']?.toString() ?? '0') ?? 0,
|
||||||
|
lng: double.tryParse(data['longitude']?.toString() ?? '0') ?? 0,
|
||||||
|
heading: double.tryParse(data['heading']?.toString() ?? '0') ?? 0,
|
||||||
|
speed: double.tryParse(data['speed']?.toString() ?? '0') ?? 0,
|
||||||
|
currentStopSeq: data['current_stop_seq'] == null
|
||||||
|
? null
|
||||||
|
: int.tryParse(data['current_stop_seq'].toString()),
|
||||||
|
);
|
||||||
|
Log.print('🚌 Live bus update: ${liveBusPosition?.lat}, ${liveBusPosition?.lng}');
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
void closeLiveRoute() {
|
||||||
|
if (liveRouteId != null && Get.isRegistered<MapSocketController>()) {
|
||||||
|
final ms = Get.find<MapSocketController>();
|
||||||
|
ms.unsubscribeFromTransitRoute(liveRouteId!);
|
||||||
|
ms.onBusLocationUpdate = null;
|
||||||
|
}
|
||||||
|
liveRouteId = null;
|
||||||
|
liveTripId = null;
|
||||||
|
liveTripData = null;
|
||||||
|
liveBusPosition = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onClose() {
|
||||||
|
closeLiveRoute();
|
||||||
|
super.onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
// transit_models.dart — نماذج بيانات مواصلاتي (جهة الراكب)
|
||||||
|
|
||||||
|
class TransitOrg {
|
||||||
|
final int id;
|
||||||
|
final String type;
|
||||||
|
final String nameAr;
|
||||||
|
final String nameEn;
|
||||||
|
final String? logoUrl;
|
||||||
|
final String city;
|
||||||
|
|
||||||
|
TransitOrg({
|
||||||
|
required this.id,
|
||||||
|
required this.type,
|
||||||
|
required this.nameAr,
|
||||||
|
required this.nameEn,
|
||||||
|
required this.city,
|
||||||
|
this.logoUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory TransitOrg.fromJson(Map<String, dynamic> j) => TransitOrg(
|
||||||
|
id: int.tryParse(j['id'].toString()) ?? 0,
|
||||||
|
type: j['type']?.toString() ?? '',
|
||||||
|
nameAr: j['name_ar']?.toString() ?? '',
|
||||||
|
nameEn: j['name_en']?.toString() ?? '',
|
||||||
|
city: j['city']?.toString() ?? '',
|
||||||
|
logoUrl: j['logo_url']?.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class TransitEnrollment {
|
||||||
|
final int id;
|
||||||
|
final int orgId;
|
||||||
|
final String orgName;
|
||||||
|
final String status; // pending | active | suspended | expired
|
||||||
|
final String verifyMethod;
|
||||||
|
|
||||||
|
TransitEnrollment({
|
||||||
|
required this.id,
|
||||||
|
required this.orgId,
|
||||||
|
required this.orgName,
|
||||||
|
required this.status,
|
||||||
|
required this.verifyMethod,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory TransitEnrollment.fromActivateResponse(
|
||||||
|
Map<String, dynamic> j, int orgId) =>
|
||||||
|
TransitEnrollment(
|
||||||
|
id: int.tryParse(j['enrollment_id'].toString()) ?? 0,
|
||||||
|
orgId: orgId,
|
||||||
|
orgName: j['org_name']?.toString() ?? '',
|
||||||
|
status: j['status']?.toString() ?? 'pending',
|
||||||
|
verifyMethod: j['verify_method']?.toString() ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class TransitStop {
|
||||||
|
final int id;
|
||||||
|
final int sequence;
|
||||||
|
final String nameAr;
|
||||||
|
final double lat;
|
||||||
|
final double lng;
|
||||||
|
final int etaOffsetMin;
|
||||||
|
final bool isMajor;
|
||||||
|
|
||||||
|
TransitStop({
|
||||||
|
required this.id,
|
||||||
|
required this.sequence,
|
||||||
|
required this.nameAr,
|
||||||
|
required this.lat,
|
||||||
|
required this.lng,
|
||||||
|
required this.etaOffsetMin,
|
||||||
|
required this.isMajor,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory TransitStop.fromJson(Map<String, dynamic> j) => TransitStop(
|
||||||
|
id: int.tryParse(j['id'].toString()) ?? 0,
|
||||||
|
sequence: int.tryParse(j['sequence'].toString()) ?? 0,
|
||||||
|
nameAr: j['name_ar']?.toString() ?? '',
|
||||||
|
lat: double.tryParse(j['latitude']?.toString() ?? '0') ?? 0,
|
||||||
|
lng: double.tryParse(j['longitude']?.toString() ?? '0') ?? 0,
|
||||||
|
etaOffsetMin: int.tryParse(j['eta_offset_min']?.toString() ?? '0') ?? 0,
|
||||||
|
isMajor: (j['is_major']?.toString() ?? '0') == '1',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class TransitRouteSummary {
|
||||||
|
final int id;
|
||||||
|
final String nameAr;
|
||||||
|
final String status;
|
||||||
|
final int stopCount;
|
||||||
|
|
||||||
|
TransitRouteSummary({
|
||||||
|
required this.id,
|
||||||
|
required this.nameAr,
|
||||||
|
required this.status,
|
||||||
|
required this.stopCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory TransitRouteSummary.fromJson(Map<String, dynamic> j) =>
|
||||||
|
TransitRouteSummary(
|
||||||
|
id: int.tryParse(j['id'].toString()) ?? 0,
|
||||||
|
nameAr: j['name_ar']?.toString() ?? '',
|
||||||
|
status: j['status']?.toString() ?? '',
|
||||||
|
stopCount: int.tryParse(j['stop_count']?.toString() ?? '0') ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class TransitLiveTrip {
|
||||||
|
final int id;
|
||||||
|
final String status;
|
||||||
|
final int delayMinutes;
|
||||||
|
final int? currentStopSeq;
|
||||||
|
final String routeName;
|
||||||
|
final String? departureTime;
|
||||||
|
final String? driverName;
|
||||||
|
|
||||||
|
TransitLiveTrip({
|
||||||
|
required this.id,
|
||||||
|
required this.status,
|
||||||
|
required this.delayMinutes,
|
||||||
|
required this.routeName,
|
||||||
|
this.currentStopSeq,
|
||||||
|
this.departureTime,
|
||||||
|
this.driverName,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory TransitLiveTrip.fromJson(Map<String, dynamic> j) => TransitLiveTrip(
|
||||||
|
id: int.tryParse(j['id'].toString()) ?? 0,
|
||||||
|
status: j['status']?.toString() ?? '',
|
||||||
|
delayMinutes: int.tryParse(j['delay_minutes']?.toString() ?? '0') ?? 0,
|
||||||
|
currentStopSeq: j['current_stop_seq'] == null
|
||||||
|
? null
|
||||||
|
: int.tryParse(j['current_stop_seq'].toString()),
|
||||||
|
routeName: j['route_name']?.toString() ?? '',
|
||||||
|
departureTime: j['departure_time']?.toString(),
|
||||||
|
driverName: j['driver_name']?.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class BusPosition {
|
||||||
|
final double lat;
|
||||||
|
final double lng;
|
||||||
|
final double heading;
|
||||||
|
final double speed;
|
||||||
|
final int? currentStopSeq;
|
||||||
|
|
||||||
|
BusPosition({
|
||||||
|
required this.lat,
|
||||||
|
required this.lng,
|
||||||
|
this.heading = 0,
|
||||||
|
this.speed = 0,
|
||||||
|
this.currentStopSeq,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory BusPosition.fromJson(Map<dynamic, dynamic> j) => BusPosition(
|
||||||
|
lat: double.tryParse(j['lat']?.toString() ?? '0') ?? 0,
|
||||||
|
lng: double.tryParse(j['lng']?.toString() ?? '0') ?? 0,
|
||||||
|
heading: double.tryParse(j['heading']?.toString() ?? '0') ?? 0,
|
||||||
|
speed: double.tryParse(j['speed']?.toString() ?? '0') ?? 0,
|
||||||
|
currentStopSeq: j['current_stop_seq'] == null
|
||||||
|
? null
|
||||||
|
: int.tryParse(j['current_stop_seq'].toString()),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
// transit_service.dart — طبقة الاتصال بـ backend/transit (جهة الراكب)
|
||||||
|
import '../functions/crud.dart';
|
||||||
|
import '../../constant/links.dart';
|
||||||
|
import 'transit_models.dart';
|
||||||
|
|
||||||
|
class TransitApiResult<T> {
|
||||||
|
final bool success;
|
||||||
|
final T? data;
|
||||||
|
final String message;
|
||||||
|
TransitApiResult(this.success, this.data, this.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
class TransitService {
|
||||||
|
static String get _base => '${AppLink.server}/transit';
|
||||||
|
|
||||||
|
/// تصفح المؤسسات المتاحة للتفعيل (قبل الانضمام)
|
||||||
|
static Future<TransitApiResult<List<TransitOrg>>> browseOrgs({
|
||||||
|
String? country,
|
||||||
|
String? search,
|
||||||
|
}) async {
|
||||||
|
final payload = <String, dynamic>{};
|
||||||
|
if (country != null) payload['country'] = country;
|
||||||
|
if (search != null && search.isNotEmpty) payload['search'] = search;
|
||||||
|
|
||||||
|
final res = await CRUD().post(link: '$_base/org/browse.php', payload: payload);
|
||||||
|
|
||||||
|
if (res is Map && res['status'] == 'success') {
|
||||||
|
final msg = res['message'];
|
||||||
|
final list = (msg is Map && msg['orgs'] is List)
|
||||||
|
? (msg['orgs'] as List)
|
||||||
|
.map((o) => TransitOrg.fromJson(Map<String, dynamic>.from(o)))
|
||||||
|
.toList()
|
||||||
|
: <TransitOrg>[];
|
||||||
|
return TransitApiResult(true, list, 'ok');
|
||||||
|
}
|
||||||
|
return TransitApiResult(false, null, _errMsg(res));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// تفعيل عضوية الراكب في مؤسسة بالرقم الجامعي/الوظيفي
|
||||||
|
static Future<TransitApiResult<TransitEnrollment>> activateEnrollment({
|
||||||
|
required int orgId,
|
||||||
|
required String studentId,
|
||||||
|
}) async {
|
||||||
|
final res = await CRUD().post(
|
||||||
|
link: '$_base/enrollment/activate.php',
|
||||||
|
payload: {'org_id': orgId.toString(), 'student_id': studentId},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res is Map && res['status'] == 'success') {
|
||||||
|
final msg = res['message'];
|
||||||
|
if (msg is Map<String, dynamic>) {
|
||||||
|
return TransitApiResult(
|
||||||
|
true,
|
||||||
|
TransitEnrollment.fromActivateResponse(msg, orgId),
|
||||||
|
msg['message']?.toString() ?? 'تم',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return TransitApiResult(false, null, _errMsg(res));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// عضوياتي في كل المؤسسات
|
||||||
|
static Future<TransitApiResult<List<TransitEnrollment>>>
|
||||||
|
getMyEnrollments() async {
|
||||||
|
final res = await CRUD().post(link: '$_base/enrollment/my_enrollments.php');
|
||||||
|
|
||||||
|
if (res is Map && res['status'] == 'success') {
|
||||||
|
final msg = res['message'];
|
||||||
|
final list = (msg is Map && msg['enrollments'] is List)
|
||||||
|
? (msg['enrollments'] as List)
|
||||||
|
.map((e) => TransitEnrollment(
|
||||||
|
id: int.tryParse(e['id'].toString()) ?? 0,
|
||||||
|
orgId: int.tryParse(e['org_id'].toString()) ?? 0,
|
||||||
|
orgName: e['org_name']?.toString() ?? '',
|
||||||
|
status: e['status']?.toString() ?? '',
|
||||||
|
verifyMethod: e['verify_method']?.toString() ?? '',
|
||||||
|
))
|
||||||
|
.toList()
|
||||||
|
: <TransitEnrollment>[];
|
||||||
|
return TransitApiResult(true, list, 'ok');
|
||||||
|
}
|
||||||
|
return TransitApiResult(false, null, _errMsg(res));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// خطوط مؤسسة معيّنة (يشترط عضوية نشطة)
|
||||||
|
static Future<TransitApiResult<List<TransitRouteSummary>>> getRoutesForOrg(
|
||||||
|
int orgId) async {
|
||||||
|
final res = await CRUD().post(
|
||||||
|
link: '$_base/route/for_org.php',
|
||||||
|
payload: {'org_id': orgId.toString()},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res is Map && res['status'] == 'success') {
|
||||||
|
final msg = res['message'];
|
||||||
|
final list = (msg is Map && msg['routes'] is List)
|
||||||
|
? (msg['routes'] as List)
|
||||||
|
.map((r) => TransitRouteSummary.fromJson(
|
||||||
|
Map<String, dynamic>.from(r)))
|
||||||
|
.toList()
|
||||||
|
: <TransitRouteSummary>[];
|
||||||
|
return TransitApiResult(true, list, 'ok');
|
||||||
|
}
|
||||||
|
return TransitApiResult(false, null, _errMsg(res));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// تفاصيل الرحلة الحية لخط (المحطات + آخر موقع معروف)
|
||||||
|
static Future<TransitApiResult<Map<String, dynamic>>> getLiveTrip({
|
||||||
|
int? tripId,
|
||||||
|
int? routeId,
|
||||||
|
}) async {
|
||||||
|
final payload = <String, dynamic>{};
|
||||||
|
if (tripId != null) payload['trip_id'] = tripId.toString();
|
||||||
|
if (routeId != null) payload['route_id'] = routeId.toString();
|
||||||
|
|
||||||
|
final res = await CRUD().post(link: '$_base/trip/live.php', payload: payload);
|
||||||
|
|
||||||
|
if (res is Map && res['status'] == 'success') {
|
||||||
|
final msg = res['message'];
|
||||||
|
if (msg is Map) return TransitApiResult(true, Map<String, dynamic>.from(msg), 'ok');
|
||||||
|
}
|
||||||
|
return TransitApiResult(false, null, _errMsg(res));
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _errMsg(dynamic res) {
|
||||||
|
if (res == 'no_internet') return 'تحقق من اتصالك بالإنترنت';
|
||||||
|
if (res == 'token_expired') return 'انتهت الجلسة، حاول مجدداً';
|
||||||
|
if (res is Map && res['message'] is String) return res['message'];
|
||||||
|
return 'حدث خطأ، حاول مجدداً';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import '../HomePage/contact_us.dart';
|
|||||||
import '../HomePage/share_app_page.dart';
|
import '../HomePage/share_app_page.dart';
|
||||||
import '../setting_page.dart';
|
import '../setting_page.dart';
|
||||||
import '../profile/passenger_profile_page.dart';
|
import '../profile/passenger_profile_page.dart';
|
||||||
|
import '../../transit/transit_home_page.dart';
|
||||||
|
|
||||||
// ─── ألوان النظام (Integrated with AppColor) ──────────────────────────────────
|
// ─── ألوان النظام (Integrated with AppColor) ──────────────────────────────────
|
||||||
Color get _kCyan => AppColor.cyanBlue;
|
Color get _kCyan => AppColor.cyanBlue;
|
||||||
@@ -148,6 +149,11 @@ class MapMenuWidget extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 12, vertical: 4),
|
horizontal: 12, vertical: 4),
|
||||||
children: [
|
children: [
|
||||||
|
MenuListItem(
|
||||||
|
title: 'Mawasalati'.tr,
|
||||||
|
icon: Icons.directions_bus_filled_rounded,
|
||||||
|
onTap: () => Get.to(() => const TransitHomePage()),
|
||||||
|
),
|
||||||
MenuListItem(
|
MenuListItem(
|
||||||
title: 'My Balance'.tr,
|
title: 'My Balance'.tr,
|
||||||
icon: Icons.account_balance_wallet_outlined,
|
icon: Icons.account_balance_wallet_outlined,
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// transit_home_page.dart — الصفحة الرئيسية لتبويب "مواصلاتي"
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
|
||||||
|
import '../../constant/colors.dart';
|
||||||
|
import '../../constant/style.dart';
|
||||||
|
import '../../controller/transit/transit_controller.dart';
|
||||||
|
import '../../controller/transit/transit_models.dart';
|
||||||
|
import '../widgets/my_scafold.dart';
|
||||||
|
import 'transit_org_browse_page.dart';
|
||||||
|
import 'transit_routes_page.dart';
|
||||||
|
|
||||||
|
class TransitHomePage extends StatelessWidget {
|
||||||
|
const TransitHomePage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
Get.put(TransitController());
|
||||||
|
|
||||||
|
return GetBuilder<TransitController>(
|
||||||
|
builder: (c) => MyScafolld(
|
||||||
|
title: 'Mawasalati'.tr,
|
||||||
|
isleading: true,
|
||||||
|
body: [
|
||||||
|
Expanded(
|
||||||
|
child: RefreshIndicator(
|
||||||
|
onRefresh: c.fetchMyEnrollments,
|
||||||
|
child: c.isLoadingEnrollments
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: c.myEnrollments.isEmpty
|
||||||
|
? _emptyState(c)
|
||||||
|
: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
Text('My Memberships'.tr,
|
||||||
|
style: AppStyle.headTitle2.copyWith(fontSize: 18)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
...c.myEnrollments.map((e) => _enrollmentCard(e, c)),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_addOrgTile(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _emptyState(TransitController c) {
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 60),
|
||||||
|
Icon(Icons.directions_bus_filled_outlined,
|
||||||
|
size: 72, color: AppColor.grayColor),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Not enrolled in any institution yet'.tr,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: AppStyle.title.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Activate your membership in your university or institution to track your bus live'.tr,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: AppStyle.subtitle.copyWith(color: AppColor.grayColor),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_addOrgTile(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _addOrgTile() {
|
||||||
|
return Card(
|
||||||
|
color: AppColor.cardColor,
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
side: BorderSide(color: AppColor.borderColor),
|
||||||
|
),
|
||||||
|
child: ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: AppColor.accentColor.withOpacity(0.15),
|
||||||
|
child: Icon(Icons.add, color: AppColor.accentColor),
|
||||||
|
),
|
||||||
|
title: Text('Join a new institution'.tr, style: AppStyle.title),
|
||||||
|
subtitle: Text('University, school, or hotel'.tr, style: AppStyle.subtitle),
|
||||||
|
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
|
||||||
|
onTap: () => Get.to(() => const TransitOrgBrowsePage()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _enrollmentCard(TransitEnrollment e, TransitController c) {
|
||||||
|
Color statusColor;
|
||||||
|
String statusText;
|
||||||
|
switch (e.status) {
|
||||||
|
case 'active':
|
||||||
|
statusColor = AppColor.greenColor;
|
||||||
|
statusText = 'Active'.tr;
|
||||||
|
break;
|
||||||
|
case 'pending':
|
||||||
|
statusColor = AppColor.yellowColor;
|
||||||
|
statusText = 'Pending Review'.tr;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
statusColor = AppColor.redColor;
|
||||||
|
statusText = 'Suspended'.tr;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
color: AppColor.cardColor,
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
side: BorderSide(color: AppColor.borderColor),
|
||||||
|
),
|
||||||
|
child: ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: AppColor.primaryColor.withOpacity(0.1),
|
||||||
|
child: Icon(Icons.school_outlined, color: AppColor.primaryColor),
|
||||||
|
),
|
||||||
|
title: Text(e.orgName, style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
|
||||||
|
subtitle: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: BoxDecoration(color: statusColor, shape: BoxShape.circle),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(statusText, style: AppStyle.subtitle.copyWith(color: statusColor)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
trailing: e.status == 'active' ? const Icon(Icons.arrow_forward_ios, size: 16) : null,
|
||||||
|
onTap: e.status == 'active'
|
||||||
|
? () => Get.to(() => TransitRoutesPage(orgId: e.orgId, orgName: e.orgName))
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
// transit_live_map_page.dart — تتبع الباص الحي على الخريطة
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:intaleq_maps/intaleq_maps.dart';
|
||||||
|
|
||||||
|
import '../../constant/colors.dart';
|
||||||
|
import '../../constant/style.dart';
|
||||||
|
import '../../env/env.dart';
|
||||||
|
import '../../controller/transit/transit_controller.dart';
|
||||||
|
import '../widgets/my_scafold.dart';
|
||||||
|
|
||||||
|
class TransitLiveMapPage extends StatefulWidget {
|
||||||
|
final int routeId;
|
||||||
|
final String routeName;
|
||||||
|
const TransitLiveMapPage({super.key, required this.routeId, required this.routeName});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TransitLiveMapPage> createState() => _TransitLiveMapPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TransitLiveMapPageState extends State<TransitLiveMapPage> {
|
||||||
|
IntaleqMapController? _mapController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
Get.find<TransitController>().openLiveRoute(widget.routeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
Get.find<TransitController>().closeLiveRoute();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GetBuilder<TransitController>(
|
||||||
|
builder: (c) {
|
||||||
|
final stops = (c.liveTripData?['stops'] is List)
|
||||||
|
? List<Map>.from(c.liveTripData!['stops'])
|
||||||
|
: <Map>[];
|
||||||
|
|
||||||
|
final markers = <Marker>{};
|
||||||
|
|
||||||
|
for (final s in stops) {
|
||||||
|
final lat = double.tryParse(s['latitude']?.toString() ?? '') ?? 0;
|
||||||
|
final lng = double.tryParse(s['longitude']?.toString() ?? '') ?? 0;
|
||||||
|
if (lat == 0 && lng == 0) continue;
|
||||||
|
markers.add(Marker(
|
||||||
|
markerId: MarkerId('stop_${s['id']}'),
|
||||||
|
position: LatLng(lat, lng),
|
||||||
|
infoWindow: InfoWindow(title: s['name_ar']?.toString() ?? ''),
|
||||||
|
icon: InlqBitmap.defaultMarkerWithHue(
|
||||||
|
(s['is_major']?.toString() ?? '0') == '1' ? 30 : 200),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
LatLng? busLatLng;
|
||||||
|
if (c.liveBusPosition != null) {
|
||||||
|
busLatLng = LatLng(c.liveBusPosition!.lat, c.liveBusPosition!.lng);
|
||||||
|
markers.add(Marker(
|
||||||
|
markerId: const MarkerId('bus'),
|
||||||
|
position: busLatLng,
|
||||||
|
rotation: c.liveBusPosition!.heading,
|
||||||
|
anchor: const Offset(0.5, 0.5),
|
||||||
|
icon: InlqBitmap.defaultMarkerWithHue(120),
|
||||||
|
));
|
||||||
|
if (_mapController != null) {
|
||||||
|
_mapController!.animateCamera(CameraUpdate.newLatLng(busLatLng));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final initialTarget = busLatLng ??
|
||||||
|
(markers.isNotEmpty ? markers.first.position : const LatLng(31.95, 35.93));
|
||||||
|
|
||||||
|
return MyScafolld(
|
||||||
|
title: widget.routeName,
|
||||||
|
isleading: true,
|
||||||
|
body: [
|
||||||
|
Expanded(
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
IntaleqMap(
|
||||||
|
apiKey: Env.mapSaasKey,
|
||||||
|
initialCameraPosition: CameraPosition(target: initialTarget, zoom: 14),
|
||||||
|
markers: markers,
|
||||||
|
onMapCreated: (ctrl) => _mapController = ctrl,
|
||||||
|
),
|
||||||
|
if (c.isLoadingLiveTrip)
|
||||||
|
const Positioned.fill(
|
||||||
|
child: ColoredBox(
|
||||||
|
color: Colors.black12,
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!c.isLoadingLiveTrip && c.liveError.isNotEmpty)
|
||||||
|
Positioned(
|
||||||
|
top: 16,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
child: _statusBanner(c.liveError, AppColor.redColor),
|
||||||
|
),
|
||||||
|
if (!c.isLoadingLiveTrip &&
|
||||||
|
c.liveError.isEmpty &&
|
||||||
|
c.liveBusPosition == null)
|
||||||
|
Positioned(
|
||||||
|
top: 16,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
child: _statusBanner(
|
||||||
|
'No bus running on this route right now'.tr, AppColor.yellowColor),
|
||||||
|
),
|
||||||
|
if (c.liveTripData?['trip']?['delay_minutes'] != null &&
|
||||||
|
(int.tryParse(c.liveTripData!['trip']['delay_minutes'].toString()) ?? 0) > 0)
|
||||||
|
Positioned(
|
||||||
|
bottom: 24,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
child: _statusBanner(
|
||||||
|
'${'Bus is delayed'.tr} ${c.liveTripData!['trip']['delay_minutes']} ${'min'.tr}',
|
||||||
|
AppColor.yellowColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _statusBanner(String text, Color color) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColor.cardColor,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border(right: BorderSide(color: color, width: 4)),
|
||||||
|
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 6)],
|
||||||
|
),
|
||||||
|
child: Text(text, style: AppStyle.title.copyWith(color: color, fontWeight: FontWeight.bold)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
// transit_org_browse_page.dart — تصفح المؤسسات + تفعيل عضوية بالرقم الجامعي
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
|
||||||
|
import '../../constant/colors.dart';
|
||||||
|
import '../../constant/style.dart';
|
||||||
|
import '../../controller/transit/transit_controller.dart';
|
||||||
|
import '../../controller/transit/transit_models.dart';
|
||||||
|
import '../widgets/elevated_btn.dart';
|
||||||
|
import '../widgets/my_scafold.dart';
|
||||||
|
|
||||||
|
class TransitOrgBrowsePage extends StatefulWidget {
|
||||||
|
const TransitOrgBrowsePage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TransitOrgBrowsePage> createState() => _TransitOrgBrowsePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TransitOrgBrowsePageState extends State<TransitOrgBrowsePage> {
|
||||||
|
final _searchCtrl = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
Get.find<TransitController>().browseOrgs();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GetBuilder<TransitController>(
|
||||||
|
builder: (c) => MyScafolld(
|
||||||
|
title: 'Choose your institution'.tr,
|
||||||
|
isleading: true,
|
||||||
|
body: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchCtrl,
|
||||||
|
onSubmitted: (v) => c.browseOrgs(search: v),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Search for a university or institution...'.tr,
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
filled: true,
|
||||||
|
fillColor: AppColor.cardColor,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(color: AppColor.borderColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: c.isLoadingOrgs
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: c.browsableOrgs.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Text('No results'.tr, style: AppStyle.title),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
itemCount: c.browsableOrgs.length,
|
||||||
|
itemBuilder: (_, i) {
|
||||||
|
final org = c.browsableOrgs[i];
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
|
color: AppColor.cardColor,
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
side: BorderSide(color: AppColor.borderColor),
|
||||||
|
),
|
||||||
|
child: ListTile(
|
||||||
|
title: Text(org.nameAr, style: AppStyle.title),
|
||||||
|
subtitle: Text(org.city, style: AppStyle.subtitle),
|
||||||
|
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
|
||||||
|
onTap: () => _showActivateSheet(context, c, org),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showActivateSheet(BuildContext context, TransitController c, TransitOrg org) {
|
||||||
|
final idCtrl = TextEditingController();
|
||||||
|
bool loading = false;
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: AppColor.cardColor,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (ctx) => StatefulBuilder(
|
||||||
|
builder: (ctx, setState) => Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
top: 20,
|
||||||
|
bottom: MediaQuery.of(ctx).viewInsets.bottom + 20,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(org.nameAr, style: AppStyle.headTitle2.copyWith(fontSize: 18)),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text('Enter your student/employee ID to activate membership'.tr,
|
||||||
|
style: AppStyle.subtitle),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: idCtrl,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Student ID'.tr,
|
||||||
|
filled: true,
|
||||||
|
fillColor: AppColor.secondaryColor,
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
MyElevatedButton(
|
||||||
|
title: 'Activate'.tr,
|
||||||
|
isLoading: loading,
|
||||||
|
onPressed: () async {
|
||||||
|
if (idCtrl.text.trim().isEmpty) return;
|
||||||
|
setState(() => loading = true);
|
||||||
|
final ok = await c.activateEnrollment(org.id, idCtrl.text.trim());
|
||||||
|
setState(() => loading = false);
|
||||||
|
if (ok && ctx.mounted) {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
Get.back(); // العودة للصفحة الرئيسية لمواصلاتي
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// transit_routes_page.dart — خطوط مؤسسة الراكب
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
|
||||||
|
import '../../constant/colors.dart';
|
||||||
|
import '../../constant/style.dart';
|
||||||
|
import '../../controller/transit/transit_controller.dart';
|
||||||
|
import '../widgets/my_scafold.dart';
|
||||||
|
import 'transit_live_map_page.dart';
|
||||||
|
|
||||||
|
class TransitRoutesPage extends StatefulWidget {
|
||||||
|
final int orgId;
|
||||||
|
final String orgName;
|
||||||
|
const TransitRoutesPage({super.key, required this.orgId, required this.orgName});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TransitRoutesPage> createState() => _TransitRoutesPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TransitRoutesPageState extends State<TransitRoutesPage> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
Get.find<TransitController>().openOrgRoutes(widget.orgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GetBuilder<TransitController>(
|
||||||
|
builder: (c) => MyScafolld(
|
||||||
|
title: widget.orgName,
|
||||||
|
isleading: true,
|
||||||
|
body: [
|
||||||
|
Expanded(
|
||||||
|
child: c.isLoadingRoutes
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: c.currentOrgRoutes.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Text('No active routes currently'.tr, style: AppStyle.title),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: c.currentOrgRoutes.length,
|
||||||
|
itemBuilder: (_, i) {
|
||||||
|
final r = c.currentOrgRoutes[i];
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
|
color: AppColor.cardColor,
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
side: BorderSide(color: AppColor.borderColor),
|
||||||
|
),
|
||||||
|
child: ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: AppColor.accentColor.withOpacity(0.15),
|
||||||
|
child: Icon(Icons.directions_bus, color: AppColor.accentColor),
|
||||||
|
),
|
||||||
|
title: Text(r.nameAr, style: AppStyle.title),
|
||||||
|
subtitle: Text('${r.stopCount} ${'Stops'.tr}', style: AppStyle.subtitle),
|
||||||
|
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
|
||||||
|
onTap: () => Get.to(
|
||||||
|
() => TransitLiveMapPage(routeId: r.id, routeName: r.nameAr),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user