diff --git a/.claude/launch.json b/.claude/launch.json
new file mode 100644
index 00000000..bcc045bb
--- /dev/null
+++ b/.claude/launch.json
@@ -0,0 +1,12 @@
+{
+ "version": "0.0.1",
+ "configurations": [
+ {
+ "name": "transit-dashboard",
+ "runtimeExecutable": "npm",
+ "runtimeArgs": ["run", "dev"],
+ "port": 5183,
+ "cwd": "transit_dashboard"
+ }
+ ]
+}
diff --git a/backend/transit/cron_approaching_alerts.php b/backend/transit/cron_approaching_alerts.php
index 7266abe9..0ead2d27 100644
--- a/backend/transit/cron_approaching_alerts.php
+++ b/backend/transit/cron_approaching_alerts.php
@@ -1,9 +1,20 @@
getMessage() . "\n";
+ exit(1);
+}
// 1. جلب الرحلات النشطة حالياً
$stTrips = $transit_con->query(
@@ -11,17 +22,23 @@ $stTrips = $transit_con->query(
);
$trips = $stTrips->fetchAll();
+if (empty($trips)) {
+ echo "No active trips. Nothing to check.\n";
+ exit(0);
+}
+
foreach ($trips as $trip) {
$tripId = (int)$trip['id'];
$routeId = (int)$trip['route_id'];
$currentSeq = (int)$trip['current_stop_seq'];
- // 2. جلب موقع الباص من Redis
+ // 2. جلب موقع الباص من Redis — تجاهل إن كان قديماً (> 3 دقائق، أي الباص فقد الاتصال)
$pos = transitGetBusPosition($tripId);
if (!$pos || empty($pos['lat'])) continue;
+ if (empty($pos['ts']) || (time() - (int)$pos['ts']) > 180) continue;
- $busLat = (double)$pos['lat'];
- $busLng = (double)$pos['lng'];
+ $busLat = (float)$pos['lat'];
+ $busLng = (float)$pos['lng'];
// 3. جلب المحطة التالية
$nextSeq = $currentSeq + 1;
@@ -35,8 +52,8 @@ foreach ($trips as $trip) {
if ($nextStop) {
$stopId = (int)$nextStop['id'];
- $stopLat = (double)$nextStop['latitude'];
- $stopLng = (double)$nextStop['longitude'];
+ $stopLat = (float)$nextStop['latitude'];
+ $stopLng = (float)$nextStop['longitude'];
$stopName = $nextStop['name_ar'];
// حساب المسافة تقريبياً
diff --git a/backend/transit/driver/suspend.php b/backend/transit/driver/suspend.php
new file mode 100644
index 00000000..be2523e4
--- /dev/null
+++ b/backend/transit/driver/suspend.php
@@ -0,0 +1,27 @@
+prepare("SELECT id, status FROM transit_drivers WHERE id=? AND org_id=? LIMIT 1");
+$chk->execute([$driverId, $transit_org_id]);
+$driver = $chk->fetch();
+if (!$driver) jsonError('Driver not found or access denied', 404);
+
+// إعادة التفعيل غير ممكنة إلا لسائق سبق تفعيله عبر الدعوة (status كان suspended)
+if ($action === 'activate' && $driver['status'] !== 'suspended') {
+ jsonError('Only a suspended driver can be reactivated', 409);
+}
+
+$newStatus = $action === 'suspend' ? 'suspended' : 'active';
+$transit_con->prepare("UPDATE transit_drivers SET status=? WHERE id=?")->execute([$newStatus, $driverId]);
+
+appLog("[TRANSIT][DRIVER][suspend] driver #{$driverId} org#{$transit_org_id} → {$newStatus}");
+
+jsonSuccess(['driver_id' => $driverId, 'status' => $newStatus]);
diff --git a/backend/transit/enrollment/rosters_list.php b/backend/transit/enrollment/rosters_list.php
new file mode 100644
index 00000000..d4a40cec
--- /dev/null
+++ b/backend/transit/enrollment/rosters_list.php
@@ -0,0 +1,14 @@
+prepare(
+ "SELECT id, filename, total_rows, matched_rows, new_enrollments, expired_count,
+ semester, notes, created_at
+ FROM transit_rosters
+ WHERE org_id=? ORDER BY created_at DESC LIMIT 50"
+);
+$st->execute([$transit_org_id]);
+
+jsonSuccess(['rosters' => $st->fetchAll()]);
diff --git a/backend/transit/functions.php b/backend/transit/functions.php
index bbfd3c75..3b9169e7 100644
--- a/backend/transit/functions.php
+++ b/backend/transit/functions.php
@@ -137,6 +137,34 @@ function transitOrgTopic(int $orgId): string
return 'transit_org_' . $orgId;
}
+// إشعار مباشر لراكب واحد عبر موضوعه الشخصي passenger_{id}
+// (التطبيق يشترك بهذا الموضوع عند تسجيل الدخول — انظر firbase_messge.dart)
+function transitSendNotificationToPassenger(string $passengerId, string $title, string $body, array $data = []): void
+{
+ if (!$passengerId) return;
+ transitSendTopicNotification('passenger_' . $passengerId, $title, $body, $data);
+}
+
+// إشعار أولياء الأمور المرتبطين بعضوية طالب — يحترم أعمدة notify_* بالجدول
+// $event: depart | board | arrive | return
+function transitNotifyGuardians(int $enrollmentId, string $event, string $title, string $body): void
+{
+ global $transit_con;
+ if (!$transit_con) return;
+
+ $column = 'notify_' . $event;
+ if (!in_array($event, ['depart', 'board', 'arrive', 'return'])) return;
+
+ $st = $transit_con->prepare(
+ "SELECT guardian_passenger_id FROM transit_guardians
+ WHERE enrollment_id=? AND {$column}=1"
+ );
+ $st->execute([$enrollmentId]);
+ foreach ($st->fetchAll(PDO::FETCH_COLUMN) as $guardianId) {
+ transitSendNotificationToPassenger((string)$guardianId, $title, $body, ['type' => 'transit_guardian_' . $event]);
+ }
+}
+
function transitSendTopicNotification(string $topic, string $title, string $body, array $data = []): void
{
global $redis;
diff --git a/backend/transit/route/list.php b/backend/transit/route/list.php
index 1ac32059..b2435f08 100644
--- a/backend/transit/route/list.php
+++ b/backend/transit/route/list.php
@@ -4,7 +4,7 @@
require_once __DIR__ . '/../../transit/connect_admin.php';
$status = filterRequest('status') ?: 'active';
-if (!in_array($status, ['all','draft','active','suspended'])) $status = 'active';
+if (!in_array($status, ['all','draft','active','suspended','rejected'])) $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];
diff --git a/backend/transit/route/update.php b/backend/transit/route/update.php
new file mode 100644
index 00000000..1090d87e
--- /dev/null
+++ b/backend/transit/route/update.php
@@ -0,0 +1,80 @@
+prepare("SELECT id, status FROM transit_routes WHERE id=? AND org_id=? LIMIT 1");
+$chk->execute([$routeId, $transit_org_id]);
+$route = $chk->fetch();
+if (!$route) jsonError('Route not found or access denied', 404);
+
+// إن كان معتمداً أو موقوفاً، التعديل يعيده لمسودة بانتظار اعتماد جديد
+$newStatus = in_array($route['status'], ['active', 'suspended']) ? 'draft' : $route['status'];
+
+$transit_con->beginTransaction();
+try {
+ $transit_con->prepare(
+ "UPDATE transit_routes
+ SET name_ar=?, name_en=?, direction=?, polyline=?, distance_km=?, duration_min=?,
+ status=?, approved_by=NULL, approved_at=NULL, updated_at=NOW()
+ WHERE id=?"
+ )->execute([
+ $nameAr,
+ filterRequest('name_en'),
+ $direction,
+ filterRequest('polyline'),
+ filterRequest('distance_km', 'float'),
+ filterRequest('duration_min', 'int'),
+ $newStatus,
+ $routeId,
+ ]);
+
+ // استبدال كامل لمحطات الخط
+ $transit_con->prepare("DELETE FROM transit_stops WHERE route_id=?")->execute([$routeId]);
+
+ $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][update] ' . $e->getMessage(), 'ERROR');
+ jsonError('Failed to update route', 500);
+}
+
+appLog("[TRANSIT][ROUTE][update] route #{$routeId} org#{$transit_org_id} updated, status={$newStatus}");
+
+jsonSuccess([
+ 'route_id' => $routeId,
+ 'status' => $newStatus,
+ 'stops_count'=> count($stops),
+], $newStatus === 'draft' ? 'Route updated — pending re-approval' : 'Route updated');
diff --git a/backend/transit/schedule/delete.php b/backend/transit/schedule/delete.php
new file mode 100644
index 00000000..c83c26a3
--- /dev/null
+++ b/backend/transit/schedule/delete.php
@@ -0,0 +1,18 @@
+prepare("SELECT id FROM transit_schedules WHERE id=? AND org_id=? LIMIT 1");
+$chk->execute([$scheduleId, $transit_org_id]);
+if (!$chk->fetch()) jsonError('Schedule not found or access denied', 404);
+
+$transit_con->prepare("DELETE FROM transit_schedules WHERE id=?")->execute([$scheduleId]);
+
+appLog("[TRANSIT][SCHEDULE][delete] schedule #{$scheduleId} org#{$transit_org_id}");
+
+jsonSuccess(['schedule_id' => $scheduleId], 'Schedule deleted');
diff --git a/backend/transit/schedule/list.php b/backend/transit/schedule/list.php
new file mode 100644
index 00000000..d900d63e
--- /dev/null
+++ b/backend/transit/schedule/list.php
@@ -0,0 +1,25 @@
+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);
+
+$st = $transit_con->prepare(
+ "SELECT s.id, s.departure_time, s.days_mask, s.valid_from, s.valid_until, s.is_active,
+ s.driver_id, s.vehicle_id, 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"
+);
+$st->execute([$routeId, $transit_org_id]);
+
+jsonSuccess(['schedules' => $st->fetchAll()]);
diff --git a/backend/transit/schema_transit.sql b/backend/transit/schema_transit.sql
index 6b3f2e73..46abd12a 100644
--- a/backend/transit/schema_transit.sql
+++ b/backend/transit/schema_transit.sql
@@ -138,7 +138,7 @@ CREATE TABLE IF NOT EXISTS `transit_routes` (
`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',
+ `status` ENUM('draft','active','suspended','rejected') 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,
@@ -337,6 +337,8 @@ SET FOREIGN_KEY_CHECKS = 1;
-- COMMENT 'معرّف في جدول passengers الرئيسي — NULL لصفوف الكشف قبل تفعيل الطالب';
-- ALTER TABLE transit_trips MODIFY vehicle_id INT UNSIGNED DEFAULT NULL
-- COMMENT 'NULL إن كان الجدول بلا باص مخصص';
+-- ALTER TABLE transit_routes MODIFY status ENUM('draft','active','suspended','rejected') NOT NULL DEFAULT 'draft';
+-- (بدونها: Admin/transit/route/approve.php بإجراء reject يفشل بخطأ SQL دائماً)
-- -----------------------------------------------------------------
-- -----------------------------------------------------------------
diff --git a/backend/transit/trip/board_passenger.php b/backend/transit/trip/board_passenger.php
index 864abf21..432e2470 100644
--- a/backend/transit/trip/board_passenger.php
+++ b/backend/transit/trip/board_passenger.php
@@ -4,6 +4,8 @@
require_once __DIR__ . '/../../transit/connect_app.php';
+if ($transit_user_role !== 'driver') jsonError('Only drivers can access this endpoint', 403);
+
$stopId = (int)filterRequest('stop_id');
$passengerId = filterRequest('passenger_id');
@@ -11,7 +13,16 @@ if (!$stopId || !$passengerId) {
jsonError('Missing parameters', 400);
}
-// 1. تحقق من السائق ورحلته الحالية
+// استخرج سجل السائق من JWT — لا نثق بأي معرّف سائق من العميل
+$driverRow = $transit_con->prepare(
+ "SELECT id FROM transit_drivers WHERE main_driver_id=? AND status='active' LIMIT 1"
+);
+$driverRow->execute([(string)$transit_user_id]);
+$driverRec = $driverRow->fetch();
+if (!$driverRec) jsonError('Driver not found or not active', 403);
+$transit_driver_id = (int)$driverRec['id'];
+
+// 1. تحقق من رحلة السائق الحالية
$stTrip = $transit_con->prepare(
"SELECT id, org_id FROM transit_trips WHERE driver_id=? AND status='started' LIMIT 1"
);
@@ -55,27 +66,11 @@ $stInsert = $transit_con->prepare(
);
$stInsert->execute([$tripId, $enrollId, $stopId]);
-// 5. إرسال إشعار FCM لولي الأمر (إن وُجد)
-$stGuard = $transit_con->prepare(
- "SELECT guardian_passenger_id FROM transit_guardians
- WHERE enrollment_id=? AND notify_board=1"
-);
-$stGuard->execute([$enrollId]);
-$guardians = $stGuard->fetchAll(PDO::FETCH_COLUMN);
+// 5. إرسال إشعار صعود — لولي الأمر (إن وُجد) وللطالب نفسه (تأكيد بمحفظته)
+$title = "صعود الحافلة 🚌";
+$body = "لقد صعد " . ($enroll['member_name'] ?? 'الطالب') . " إلى الحافلة بنجاح.";
-// إذا كان الطالب نفسه يملك التطبيق، أرسل له أيضاً (للتأكيد في محفظته)
-$guardians[] = $passengerId;
-
-if (!empty($guardians)) {
- $title = "صعود الحافلة 🚌";
- $body = "لقد صعد " . ($enroll['member_name'] ?? 'الطالب') . " إلى الحافلة بنجاح.";
-
- // إرسال الإشعار باستخدام دالة الإشعارات الأساسية بالنظام (عبر passenger_id)
- foreach ($guardians as $gId) {
- // يمكنك هنا مناداة دالة sendFCMToPassenger الموجودة في functions.php الأساسية
- // للتبسيط، نكتفي بافتراض وجود دالة transitSendNotificationToPassenger أو نستخدم FCM_Engine
- transitSendNotificationToPassenger($gId, $title, $body);
- }
-}
+transitNotifyGuardians($enrollId, 'board', $title, $body);
+transitSendNotificationToPassenger((string)$passengerId, $title, $body, ['type' => 'transit_boarded']);
jsonSuccess(['boarded' => true]);
diff --git a/backend/transit/trip/cancel.php b/backend/transit/trip/cancel.php
new file mode 100644
index 00000000..da822ddb
--- /dev/null
+++ b/backend/transit/trip/cancel.php
@@ -0,0 +1,47 @@
+prepare(
+ "SELECT t.id, t.status, 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.org_id=? LIMIT 1"
+);
+$st->execute([$tripId, $transit_org_id]);
+$trip = $st->fetch();
+if (!$trip) jsonError('Trip not found or access denied', 404);
+
+if (in_array($trip['status'], ['completed', 'cancelled'])) {
+ jsonError('Trip is already ' . $trip['status'], 409);
+}
+
+$wasStarted = $trip['status'] === 'started';
+
+$transit_con->prepare(
+ "UPDATE transit_trips SET status='cancelled', delay_reason=?, updated_at=NOW() WHERE id=?"
+)->execute([filterRequest('reason'), $tripId]);
+
+// إن كانت الرحلة قد بدأت فعلياً، أوقف بث الباص وحرّر ملكية الرحلة فوراً
+if ($wasStarted) {
+ transitClearTripOwner($tripId);
+ global $redisLocation;
+ if ($redisLocation) $redisLocation->del("transit:trip:{$tripId}:pos");
+ global $redis;
+ if ($redis) $redis->del("transit:trip:{$tripId}:status");
+}
+
+transitSendTopicNotification(
+ transitRouteTopic((int)$trip['route_id']),
+ 'تم إلغاء الرحلة',
+ 'أُلغيت رحلة خط ' . $trip['route_name'] . ' اليوم.',
+ ['type' => 'transit_cancelled', 'trip_id' => (string)$tripId]
+);
+
+appLog("[TRANSIT][TRIP][cancel] trip #{$tripId} org#{$transit_org_id} cancelled by admin #{$transit_admin_id}");
+
+jsonSuccess(['trip_id' => $tripId, 'status' => 'cancelled'], 'Trip cancelled');
diff --git a/backend/transit/trip/end.php b/backend/transit/trip/end.php
index 305bca83..f61317c1 100644
--- a/backend/transit/trip/end.php
+++ b/backend/transit/trip/end.php
@@ -18,7 +18,9 @@ if (!$driverRec) jsonError('Driver not found or not active', 403);
$driverId = (int)$driverRec['id'];
$st = $transit_con->prepare(
- "SELECT id, route_id, status FROM transit_trips WHERE id=? AND driver_id=? LIMIT 1"
+ "SELECT t.id, t.route_id, t.status, r.name_ar AS route_name, r.direction
+ FROM transit_trips t JOIN transit_routes r ON r.id = t.route_id
+ WHERE t.id=? AND t.driver_id=? LIMIT 1"
);
$st->execute([$tripId, $driverId]);
$trip = $st->fetch();
@@ -38,4 +40,19 @@ transitClearTripOwner($tripId);
global $redisLocation;
if ($redisLocation) $redisLocation->del("transit:trip:{$tripId}:pos");
+// إشعار أولياء الأمور — وصول (ذهاب) أو عودة (إياب) حسب اتجاه الخط
+$guardEvent = ($trip['direction'] === 'inbound') ? 'return' : 'arrive';
+$guardTitle = ($guardEvent === 'return') ? 'عاد الباص 🏠' : 'وصل الباص 🎓';
+$guardBody = ($guardEvent === 'return')
+ ? 'وصل باص خط ' . $trip['route_name'] . ' إلى محطته الأخيرة.'
+ : 'وصل باص خط ' . $trip['route_name'] . ' إلى وجهته.';
+
+$stGuardEnroll = $transit_con->prepare(
+ "SELECT id FROM transit_enrollments WHERE preferred_route_id=? AND status='active'"
+);
+$stGuardEnroll->execute([(int)$trip['route_id']]);
+foreach ($stGuardEnroll->fetchAll(PDO::FETCH_COLUMN) as $enrollId) {
+ transitNotifyGuardians((int)$enrollId, $guardEvent, $guardTitle, $guardBody);
+}
+
jsonSuccess(['trip_id' => $tripId, 'status' => 'completed'], 'Trip completed');
diff --git a/backend/transit/trip/start.php b/backend/transit/trip/start.php
index ad95fa48..c13d63c4 100644
--- a/backend/transit/trip/start.php
+++ b/backend/transit/trip/start.php
@@ -56,4 +56,18 @@ transitSendTopicNotification(
['type' => 'transit_started', 'trip_id' => (string)$tripId]
);
+// إشعار أولياء أمور جميع الأعضاء المسجَّلين على هذا الخط بانطلاق الباص
+$stGuardEnroll = $transit_con->prepare(
+ "SELECT id FROM transit_enrollments WHERE preferred_route_id=? AND status='active'"
+);
+$stGuardEnroll->execute([(int)$trip['route_id']]);
+foreach ($stGuardEnroll->fetchAll(PDO::FETCH_COLUMN) as $enrollId) {
+ transitNotifyGuardians(
+ (int)$enrollId,
+ 'depart',
+ 'انطلق الباص 🚌',
+ 'انطلق باص خط ' . $trip['route_name'] . ' الآن.'
+ );
+}
+
jsonSuccess(['trip_id' => $tripId, 'status' => 'started', 'started_at' => date('Y-m-d H:i:s')], 'Trip started');
diff --git a/backend/transit/trip/stop_passengers.php b/backend/transit/trip/stop_passengers.php
index 0f5f1072..c4ddc25e 100644
--- a/backend/transit/trip/stop_passengers.php
+++ b/backend/transit/trip/stop_passengers.php
@@ -4,10 +4,21 @@
require_once __DIR__ . '/../../transit/connect_app.php';
+if ($transit_user_role !== 'driver') jsonError('Only drivers can access this endpoint', 403);
+
$stopId = (int)filterRequest('stop_id');
if (!$stopId) jsonError('Missing stop_id', 400);
-// 1. تحقق من السائق ورحلته
+// استخرج سجل السائق من JWT — لا نثق بأي معرّف سائق من العميل
+$driverRow = $transit_con->prepare(
+ "SELECT id FROM transit_drivers WHERE main_driver_id=? AND status='active' LIMIT 1"
+);
+$driverRow->execute([(string)$transit_user_id]);
+$driverRec = $driverRow->fetch();
+if (!$driverRec) jsonError('Driver not found or not active', 403);
+$transit_driver_id = (int)$driverRec['id'];
+
+// 1. تحقق من رحلة السائق
$stTrip = $transit_con->prepare(
"SELECT id, org_id FROM transit_trips WHERE driver_id=? AND status='started' LIMIT 1"
);
diff --git a/backend/transit/vehicle/toggle.php b/backend/transit/vehicle/toggle.php
new file mode 100644
index 00000000..3e17c566
--- /dev/null
+++ b/backend/transit/vehicle/toggle.php
@@ -0,0 +1,18 @@
+prepare("SELECT id FROM transit_vehicles WHERE id=? AND org_id=? LIMIT 1");
+$chk->execute([$vehicleId, $transit_org_id]);
+if (!$chk->fetch()) jsonError('Vehicle not found or access denied', 404);
+
+$transit_con->prepare("UPDATE transit_vehicles SET is_active=? WHERE id=?")
+ ->execute([$isActive ? 1 : 0, $vehicleId]);
+
+jsonSuccess(['vehicle_id' => $vehicleId, 'is_active' => (bool)$isActive]);
diff --git a/backend/transit/vehicle/update.php b/backend/transit/vehicle/update.php
new file mode 100644
index 00000000..12b0e702
--- /dev/null
+++ b/backend/transit/vehicle/update.php
@@ -0,0 +1,38 @@
+prepare("SELECT id FROM transit_vehicles WHERE id=? AND org_id=? LIMIT 1");
+$chk->execute([$vehicleId, $transit_org_id]);
+if (!$chk->fetch()) jsonError('Vehicle not found or access denied', 404);
+
+$plate = filterRequest('plate');
+if ($plate) {
+ $plate = strtoupper($plate);
+ $dup = $transit_con->prepare("SELECT id FROM transit_vehicles WHERE plate=? AND id<>? LIMIT 1");
+ $dup->execute([$plate, $vehicleId]);
+ if ($dup->fetch()) jsonError('Vehicle plate already registered', 409);
+}
+
+$vType = filterRequest('vehicle_type');
+if ($vType && !in_array($vType, ['bus','minibus','van','other'])) $vType = null;
+
+$transit_con->prepare(
+ "UPDATE transit_vehicles
+ SET plate=COALESCE(?, plate), make=COALESCE(?, make), model=COALESCE(?, model),
+ year=COALESCE(?, year), color=COALESCE(?, color), capacity=COALESCE(?, capacity),
+ vehicle_type=COALESCE(?, vehicle_type), notes=COALESCE(?, notes)
+ WHERE id=?"
+)->execute([
+ $plate, filterRequest('make'), filterRequest('model'),
+ filterRequest('year', 'int'), filterRequest('color'), filterRequest('capacity', 'int'),
+ $vType, filterRequest('notes'),
+ $vehicleId,
+]);
+
+jsonSuccess(['vehicle_id' => $vehicleId], 'Vehicle updated');
diff --git a/siro_driver/lib/controller/transit/transit_driver_controller.dart b/siro_driver/lib/controller/transit/transit_driver_controller.dart
index d7c5c669..a34679f1 100644
--- a/siro_driver/lib/controller/transit/transit_driver_controller.dart
+++ b/siro_driver/lib/controller/transit/transit_driver_controller.dart
@@ -3,8 +3,8 @@ import 'package:geolocator/geolocator.dart';
import 'package:get/get.dart';
import 'package:intaleq_maps/intaleq_maps.dart' show LatLng;
+import '../../views/widgets/error_snakbar.dart';
import '../functions/location_controller.dart';
-import '../widgets/error_snakbar.dart';
import 'transit_driver_models.dart';
import 'transit_driver_service.dart';
@@ -41,7 +41,8 @@ class TransitDriverController extends GetxController {
if (res.success && res.data != null) {
isBusDriver = res.data!['is_bus_driver'] == true;
if (isBusDriver) {
- driverTransitId = int.tryParse(res.data!['driver_transit_id'].toString());
+ driverTransitId =
+ int.tryParse(res.data!['driver_transit_id'].toString());
orgName = res.data!['org_name']?.toString() ?? '';
await fetchTodayTrips();
}
@@ -184,8 +185,10 @@ class TransitDriverController extends GetxController {
for (final stop in stops) {
if (stop.sequence <= _lastAnnouncedStopSeq) continue;
final dist = Geolocator.distanceBetween(
- pos.latitude, pos.longitude,
- stop.latitude, stop.longitude,
+ pos.latitude,
+ pos.longitude,
+ stop.latitude,
+ stop.longitude,
);
if (dist <= stop.geofenceRadius) {
nextSeq = stop.sequence;
diff --git a/transit_dashboard/.env.example b/transit_dashboard/.env.example
new file mode 100644
index 00000000..8a6a632f
--- /dev/null
+++ b/transit_dashboard/.env.example
@@ -0,0 +1,3 @@
+# رابط الباك اند — يشير إلى backend/transit عبر connect_admin.php
+# VITE_API_BASE=https://api.siromove.com/transit
+VITE_API_BASE=https://jordan-siro.intaleqapp.com
diff --git a/transit_dashboard/.gitignore b/transit_dashboard/.gitignore
new file mode 100644
index 00000000..01473fa9
--- /dev/null
+++ b/transit_dashboard/.gitignore
@@ -0,0 +1,4 @@
+node_modules
+dist
+.env
+*.local
diff --git a/transit_dashboard/README.md b/transit_dashboard/README.md
new file mode 100644
index 00000000..964fb4a9
--- /dev/null
+++ b/transit_dashboard/README.md
@@ -0,0 +1,39 @@
+# لوحة مشرف المؤسسة — مواصلاتي
+
+واجهة ويب (Vue 3 + Vite) يستخدمها مشرف الجامعة/الفندق/الشركة لإدارة أسطول
+الباصات، الخطوط، الجداول، الطلاب، والإعلانات. تتصل حصراً بـ
+`backend/transit/*` عبر بوابة `connect_admin.php` (مصادقة هاتف + OTP، جلسة
+Bearer صالحة 24 ساعة).
+
+## التشغيل محلياً
+
+```bash
+cp .env.example .env # اضبط VITE_API_BASE على رابط الباك اند
+npm install
+npm run dev # http://localhost:5183
+```
+
+## البناء للإنتاج
+
+```bash
+npm run build # يُنتج dist/
+```
+
+انشر محتوى `dist/` على `transit.siromove.com` (أو `admin.siromove.com` — كلا
+الأصلين مضبوطان في CORS ضمن `backend/transit/connect_admin.php` عبر متغير
+البيئة `TRANSIT_ADMIN_ORIGINS`).
+
+## البنية
+
+- `src/api/` — عميل HTTP (`client.js`) وكل نداءات `transit/*` (`transit.js`)
+- `src/stores/auth.js` — جلسة المشرف (Pinia) وتخزين التوكن في `localStorage`
+- `src/components/MapRouteBuilder.vue` — منشئ خطوط بخريطة Leaflet (نقر لإضافة
+ محطة بالترتيب، سحب لتعديل الموقع) — لا يتطلب مفتاح API (OpenStreetMap)
+- `src/views/RouteEditorView.vue` — محرر خط كامل (بيانات + محطات + جداول)
+- الأيام في الجداول الزمنية: قناع بت (`bit0=أحد … bit6=سبت`، 62=الأحد–الخميس)
+
+## ملاحظات أمنية
+
+- لا يوجد أي اتصال مباشر بقواعد main/ride/tracking — فقط `siroTransitDb` عبر
+ الباك اند.
+- التوكن يُحذف تلقائياً من `localStorage` عند استجابة 401 من الخادم.
diff --git a/transit_dashboard/index.html b/transit_dashboard/index.html
new file mode 100644
index 00000000..d7debea8
--- /dev/null
+++ b/transit_dashboard/index.html
@@ -0,0 +1,19 @@
+
+
+
+
+
+ مواصلاتي — لوحة المؤسسة
+
+
+
+
+
+
+
+
+
+
diff --git a/transit_dashboard/package-lock.json b/transit_dashboard/package-lock.json
new file mode 100644
index 00000000..b42f1bcb
--- /dev/null
+++ b/transit_dashboard/package-lock.json
@@ -0,0 +1,1203 @@
+{
+ "name": "transit-dashboard",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "transit-dashboard",
+ "version": "1.0.0",
+ "dependencies": {
+ "leaflet": "^1.9.4",
+ "pinia": "^2.1.7",
+ "vue": "^3.4.21",
+ "vue-router": "^4.3.0"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^5.0.4",
+ "vite": "^5.2.8"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
+ "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
+ "dependencies": {
+ "@babel/types": "^7.29.7"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
+ "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
+ "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz",
+ "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz",
+ "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz",
+ "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz",
+ "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz",
+ "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz",
+ "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz",
+ "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz",
+ "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz",
+ "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz",
+ "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz",
+ "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz",
+ "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz",
+ "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz",
+ "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz",
+ "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz",
+ "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz",
+ "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz",
+ "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz",
+ "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz",
+ "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz",
+ "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz",
+ "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true
+ },
+ "node_modules/@vitejs/plugin-vue": {
+ "version": "5.2.4",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
+ "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
+ "dev": true,
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^5.0.0 || ^6.0.0",
+ "vue": "^3.2.25"
+ }
+ },
+ "node_modules/@vue/compiler-core": {
+ "version": "3.5.39",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.39.tgz",
+ "integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==",
+ "dependencies": {
+ "@babel/parser": "^7.29.7",
+ "@vue/shared": "3.5.39",
+ "entities": "^7.0.1",
+ "estree-walker": "^2.0.2",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-dom": {
+ "version": "3.5.39",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz",
+ "integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==",
+ "dependencies": {
+ "@vue/compiler-core": "3.5.39",
+ "@vue/shared": "3.5.39"
+ }
+ },
+ "node_modules/@vue/compiler-sfc": {
+ "version": "3.5.39",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz",
+ "integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==",
+ "dependencies": {
+ "@babel/parser": "^7.29.7",
+ "@vue/compiler-core": "3.5.39",
+ "@vue/compiler-dom": "3.5.39",
+ "@vue/compiler-ssr": "3.5.39",
+ "@vue/shared": "3.5.39",
+ "estree-walker": "^2.0.2",
+ "magic-string": "^0.30.21",
+ "postcss": "^8.5.15",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-ssr": {
+ "version": "3.5.39",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz",
+ "integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.39",
+ "@vue/shared": "3.5.39"
+ }
+ },
+ "node_modules/@vue/devtools-api": {
+ "version": "6.6.4",
+ "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
+ "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
+ },
+ "node_modules/@vue/reactivity": {
+ "version": "3.5.39",
+ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.39.tgz",
+ "integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==",
+ "dependencies": {
+ "@vue/shared": "3.5.39"
+ }
+ },
+ "node_modules/@vue/runtime-core": {
+ "version": "3.5.39",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.39.tgz",
+ "integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==",
+ "dependencies": {
+ "@vue/reactivity": "3.5.39",
+ "@vue/shared": "3.5.39"
+ }
+ },
+ "node_modules/@vue/runtime-dom": {
+ "version": "3.5.39",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz",
+ "integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==",
+ "dependencies": {
+ "@vue/reactivity": "3.5.39",
+ "@vue/runtime-core": "3.5.39",
+ "@vue/shared": "3.5.39",
+ "csstype": "^3.2.3"
+ }
+ },
+ "node_modules/@vue/server-renderer": {
+ "version": "3.5.39",
+ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.39.tgz",
+ "integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==",
+ "dependencies": {
+ "@vue/compiler-ssr": "3.5.39",
+ "@vue/shared": "3.5.39"
+ },
+ "peerDependencies": {
+ "vue": "3.5.39"
+ }
+ },
+ "node_modules/@vue/shared": {
+ "version": "3.5.39",
+ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz",
+ "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA=="
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
+ },
+ "node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/leaflet": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
+ "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.16",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
+ "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
+ },
+ "node_modules/pinia": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz",
+ "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==",
+ "dependencies": {
+ "@vue/devtools-api": "^6.6.3",
+ "vue-demi": "^0.14.10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/posva"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.4.4",
+ "vue": "^2.7.0 || ^3.5.11"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.19",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
+ "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "nanoid": "^3.3.12",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
+ "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
+ "dev": true,
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.62.2",
+ "@rollup/rollup-android-arm64": "4.62.2",
+ "@rollup/rollup-darwin-arm64": "4.62.2",
+ "@rollup/rollup-darwin-x64": "4.62.2",
+ "@rollup/rollup-freebsd-arm64": "4.62.2",
+ "@rollup/rollup-freebsd-x64": "4.62.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.62.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.62.2",
+ "@rollup/rollup-linux-arm64-musl": "4.62.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.62.2",
+ "@rollup/rollup-linux-loong64-musl": "4.62.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.62.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.62.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.62.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.62.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-musl": "4.62.2",
+ "@rollup/rollup-openbsd-x64": "4.62.2",
+ "@rollup/rollup-openharmony-arm64": "4.62.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.62.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.62.2",
+ "@rollup/rollup-win32-x64-gnu": "4.62.2",
+ "@rollup/rollup-win32-x64-msvc": "4.62.2",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "5.4.21",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "dev": true,
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue": {
+ "version": "3.5.39",
+ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz",
+ "integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.39",
+ "@vue/compiler-sfc": "3.5.39",
+ "@vue/runtime-dom": "3.5.39",
+ "@vue/server-renderer": "3.5.39",
+ "@vue/shared": "3.5.39"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue-demi": {
+ "version": "0.14.10",
+ "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
+ "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
+ "hasInstallScript": true,
+ "bin": {
+ "vue-demi-fix": "bin/vue-demi-fix.js",
+ "vue-demi-switch": "bin/vue-demi-switch.js"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "@vue/composition-api": "^1.0.0-rc.1",
+ "vue": "^3.0.0-0 || ^2.6.0"
+ },
+ "peerDependenciesMeta": {
+ "@vue/composition-api": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue-router": {
+ "version": "4.6.4",
+ "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
+ "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
+ "dependencies": {
+ "@vue/devtools-api": "^6.6.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/posva"
+ },
+ "peerDependencies": {
+ "vue": "^3.5.0"
+ }
+ }
+ }
+}
diff --git a/transit_dashboard/package.json b/transit_dashboard/package.json
new file mode 100644
index 00000000..b00b7ba2
--- /dev/null
+++ b/transit_dashboard/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "transit-dashboard",
+ "private": true,
+ "version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "vue": "^3.4.21",
+ "vue-router": "^4.3.0",
+ "pinia": "^2.1.7",
+ "leaflet": "^1.9.4"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^5.0.4",
+ "vite": "^5.2.8"
+ }
+}
diff --git a/transit_dashboard/src/App.vue b/transit_dashboard/src/App.vue
new file mode 100644
index 00000000..98240aef
--- /dev/null
+++ b/transit_dashboard/src/App.vue
@@ -0,0 +1,3 @@
+
+
+
diff --git a/transit_dashboard/src/api/client.js b/transit_dashboard/src/api/client.js
new file mode 100644
index 00000000..5478681d
--- /dev/null
+++ b/transit_dashboard/src/api/client.js
@@ -0,0 +1,67 @@
+// api/client.js — عميل HTTP رفيع لبوابة transit/connect_admin.php
+// كل الردود بنمط {status:'success'|'failure', message: ...}
+
+const API_BASE = import.meta.env.VITE_API_BASE || 'https://api.siromove.com/transit'
+const TOKEN_KEY = 'transit_admin_token'
+
+export function getToken() {
+ return localStorage.getItem(TOKEN_KEY) || ''
+}
+export function setToken(token) {
+ if (token) localStorage.setItem(TOKEN_KEY, token)
+ else localStorage.removeItem(TOKEN_KEY)
+}
+
+class ApiError extends Error {
+ constructor(message, status) {
+ super(message)
+ this.status = status
+ }
+}
+
+async function request(path, body, { isForm = false } = {}) {
+ const headers = {}
+ const token = getToken()
+ if (token) headers['Authorization'] = `Bearer ${token}`
+
+ let payload
+ if (isForm) {
+ payload = body // already FormData
+ } else {
+ headers['Content-Type'] = 'application/x-www-form-urlencoded'
+ const params = new URLSearchParams()
+ Object.entries(body || {}).forEach(([k, v]) => {
+ if (v !== undefined && v !== null) params.append(k, v)
+ })
+ payload = params
+ }
+
+ let res
+ try {
+ res = await fetch(`${API_BASE}/${path}`, { method: 'POST', headers, body: payload })
+ } catch (e) {
+ throw new ApiError('تعذّر الاتصال بالخادم — تحقق من اتصالك بالإنترنت', 0)
+ }
+
+ if (res.status === 401) {
+ setToken(null)
+ throw new ApiError('انتهت الجلسة — سجّل الدخول مجدداً', 401)
+ }
+
+ let json
+ try {
+ json = await res.json()
+ } catch (e) {
+ throw new ApiError('استجابة غير صالحة من الخادم', res.status)
+ }
+
+ if (json.status !== 'success') {
+ const msg = typeof json.message === 'string' ? json.message : 'حدث خطأ، حاول مجدداً'
+ throw new ApiError(msg, res.status)
+ }
+
+ return json.message
+}
+
+export const api = { request }
+export { ApiError }
diff --git a/transit_dashboard/src/api/transit.js b/transit_dashboard/src/api/transit.js
new file mode 100644
index 00000000..b80b775f
--- /dev/null
+++ b/transit_dashboard/src/api/transit.js
@@ -0,0 +1,54 @@
+// api/transit.js — كل نداءات backend/transit/* (بوابة connect_admin.php)
+import { api } from './client'
+
+// ── الدخول ──────────────────────────────────────────────
+export const loginRequest = (phone) => api.request('admin/login_request.php', { phone })
+export const loginVerify = (phone, otp) => api.request('admin/login_verify.php', { phone, otp })
+export const logout = () => api.request('admin/logout.php', {})
+
+// ── لوحة اليوم ──────────────────────────────────────────
+export const getDashboard = () => api.request('admin/dashboard.php', {})
+
+// ── الأسطول: مركبات ─────────────────────────────────────
+export const listVehicles = () => api.request('vehicle/list.php', {})
+export const addVehicle = (v) => api.request('vehicle/add.php', v)
+export const updateVehicle = (v) => api.request('vehicle/update.php', v)
+export const toggleVehicle = (vehicle_id, is_active) =>
+ api.request('vehicle/toggle.php', { vehicle_id, is_active: is_active ? 1 : 0 })
+
+// ── الأسطول: سائقون ──────────────────────────────────────
+export const listDrivers = (status = 'all') => api.request('driver/list.php', { status })
+export const inviteDriver = (d) => api.request('driver/invite.php', d)
+export const suspendDriver = (driver_id, action) => api.request('driver/suspend.php', { driver_id, action })
+
+// ── الخطوط ──────────────────────────────────────────────
+export const listRoutes = (status = 'all') => api.request('route/list.php', { status })
+export const getRoute = (route_id) => api.request('route/get.php', { route_id })
+export const addRoute = (payload) =>
+ api.request('route/add.php', { ...payload, stops: JSON.stringify(payload.stops || []) })
+export const updateRoute = (payload) =>
+ api.request('route/update.php', { ...payload, stops: JSON.stringify(payload.stops || []) })
+
+// ── الجداول ─────────────────────────────────────────────
+export const listSchedules = (route_id) => api.request('schedule/list.php', { route_id })
+export const addSchedule = (s) => api.request('schedule/add.php', s)
+export const deleteSchedule = (schedule_id) => api.request('schedule/delete.php', { schedule_id })
+
+// ── الطلاب / الأعضاء ────────────────────────────────────
+export const listEnrollments = (status = 'all', page = 1) =>
+ api.request('enrollment/list.php', { status, page })
+export const approveEnrollment = (enrollment_id, action, expires_at) =>
+ api.request('enrollment/approve.php', { enrollment_id, action, expires_at })
+export const listRosters = () => api.request('enrollment/rosters_list.php', {})
+export const importRoster = (file, semester) => {
+ const fd = new FormData()
+ fd.append('file', file)
+ if (semester) fd.append('semester', semester)
+ return api.request('enrollment/import_roster.php', fd, { isForm: true })
+}
+
+// ── الرحلات ─────────────────────────────────────────────
+export const cancelTrip = (trip_id, reason) => api.request('trip/cancel.php', { trip_id, reason })
+
+// ── الإعلانات ───────────────────────────────────────────
+export const sendBroadcast = (b) => api.request('broadcast/send.php', b)
diff --git a/transit_dashboard/src/components/AppShell.vue b/transit_dashboard/src/components/AppShell.vue
new file mode 100644
index 00000000..a70856dc
--- /dev/null
+++ b/transit_dashboard/src/components/AppShell.vue
@@ -0,0 +1,103 @@
+
+
+
+
+
+
+
diff --git a/transit_dashboard/src/components/ConfirmDialog.vue b/transit_dashboard/src/components/ConfirmDialog.vue
new file mode 100644
index 00000000..56a74097
--- /dev/null
+++ b/transit_dashboard/src/components/ConfirmDialog.vue
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
{{ title }}
+
{{ body }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/transit_dashboard/src/components/DayMaskPicker.vue b/transit_dashboard/src/components/DayMaskPicker.vue
new file mode 100644
index 00000000..6314d6d9
--- /dev/null
+++ b/transit_dashboard/src/components/DayMaskPicker.vue
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
diff --git a/transit_dashboard/src/components/MapRouteBuilder.vue b/transit_dashboard/src/components/MapRouteBuilder.vue
new file mode 100644
index 00000000..504e9ee0
--- /dev/null
+++ b/transit_dashboard/src/components/MapRouteBuilder.vue
@@ -0,0 +1,110 @@
+
+
+
+
+
+
انقر على الخريطة لإضافة محطة بالترتيب — اسحب أي علامة لتعديل موقعها
+
+
+
+
+
+
diff --git a/transit_dashboard/src/components/StatCard.vue b/transit_dashboard/src/components/StatCard.vue
new file mode 100644
index 00000000..73febfc7
--- /dev/null
+++ b/transit_dashboard/src/components/StatCard.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
{{ value }}
+
{{ label }}
+
+
+
+
diff --git a/transit_dashboard/src/main.js b/transit_dashboard/src/main.js
new file mode 100644
index 00000000..6d31d5f8
--- /dev/null
+++ b/transit_dashboard/src/main.js
@@ -0,0 +1,7 @@
+import { createApp } from 'vue'
+import { createPinia } from 'pinia'
+import App from './App.vue'
+import router from './router'
+import './styles/main.css'
+
+createApp(App).use(createPinia()).use(router).mount('#app')
diff --git a/transit_dashboard/src/router/index.js b/transit_dashboard/src/router/index.js
new file mode 100644
index 00000000..4aecaea7
--- /dev/null
+++ b/transit_dashboard/src/router/index.js
@@ -0,0 +1,32 @@
+import { createRouter, createWebHistory } from 'vue-router'
+import { useAuthStore } from '../stores/auth'
+
+const routes = [
+ { path: '/login', name: 'login', component: () => import('../views/LoginView.vue'), meta: { public: true } },
+ {
+ path: '/',
+ component: () => import('../components/AppShell.vue'),
+ children: [
+ { path: '', redirect: '/dashboard' },
+ { path: 'dashboard', name: 'dashboard', component: () => import('../views/DashboardView.vue') },
+ { path: 'fleet', name: 'fleet', component: () => import('../views/FleetView.vue') },
+ { path: 'routes', name: 'routes', component: () => import('../views/RoutesView.vue') },
+ { path: 'routes/:id', name: 'route-editor', component: () => import('../views/RouteEditorView.vue'), props: true },
+ { path: 'routes/new', name: 'route-new', component: () => import('../views/RouteEditorView.vue') },
+ { path: 'students', name: 'students', component: () => import('../views/StudentsView.vue') },
+ { path: 'broadcasts', name: 'broadcasts', component: () => import('../views/BroadcastsView.vue') },
+ ],
+ },
+ { path: '/:pathMatch(.*)*', redirect: '/dashboard' },
+]
+
+const router = createRouter({ history: createWebHistory(), routes })
+
+router.beforeEach((to) => {
+ const auth = useAuthStore()
+ if (!to.meta.public && !auth.isLoggedIn) return '/login'
+ if (to.name === 'login' && auth.isLoggedIn) return '/dashboard'
+ return true
+})
+
+export default router
diff --git a/transit_dashboard/src/stores/auth.js b/transit_dashboard/src/stores/auth.js
new file mode 100644
index 00000000..573a4de0
--- /dev/null
+++ b/transit_dashboard/src/stores/auth.js
@@ -0,0 +1,35 @@
+import { defineStore } from 'pinia'
+import { getToken, setToken } from '../api/client'
+import * as transitApi from '../api/transit'
+
+export const useAuthStore = defineStore('auth', {
+ state: () => ({
+ token: getToken(),
+ admin: JSON.parse(localStorage.getItem('transit_admin_profile') || 'null'),
+ org: JSON.parse(localStorage.getItem('transit_admin_org') || 'null'),
+ }),
+ getters: {
+ isLoggedIn: (state) => !!state.token,
+ },
+ actions: {
+ async verifyOtp(phone, otp) {
+ const res = await transitApi.loginVerify(phone, otp)
+ this.token = res.token
+ this.admin = res.admin
+ this.org = res.org
+ setToken(res.token)
+ localStorage.setItem('transit_admin_profile', JSON.stringify(res.admin))
+ localStorage.setItem('transit_admin_org', JSON.stringify(res.org))
+ return res
+ },
+ async logout() {
+ try { await transitApi.logout() } catch (e) { /* الجلسة قد تكون منتهية أصلاً */ }
+ this.token = ''
+ this.admin = null
+ this.org = null
+ setToken(null)
+ localStorage.removeItem('transit_admin_profile')
+ localStorage.removeItem('transit_admin_org')
+ },
+ },
+})
diff --git a/transit_dashboard/src/styles/main.css b/transit_dashboard/src/styles/main.css
new file mode 100644
index 00000000..c8c2b871
--- /dev/null
+++ b/transit_dashboard/src/styles/main.css
@@ -0,0 +1,168 @@
+/* ============================================================
+ مواصلاتي — لوحة المؤسسة: نظام تصميم
+ هوية هادئة وواثقة لأداة تشغيلية يومية (ليست صفحة تسويقية)
+ ============================================================ */
+
+:root {
+ --bg: #F4F6F9;
+ --surface: #FFFFFF;
+ --surface-2: #EAEEF3;
+ --ink: #101826;
+ --ink-soft: #4A5568;
+ --ink-faint: #8896A6;
+ --line: #E1E6ED;
+ --line-strong: #C9D1DD;
+
+ --brand: #0E7C86; /* أزرق-تيل الميناء — هوية اللوحة الخاصة، مختلفة عن سيرو الأساسي */
+ --brand-deep: #0A5F67;
+ --brand-soft: #E1F2F3;
+
+ --good: #1F9D6B;
+ --good-soft: #E3F6EC;
+ --warn: #C9821D;
+ --warn-soft: #FBF0DD;
+ --bad: #C6473D;
+ --bad-soft: #FAE6E3;
+ --info: #4A6FA5;
+ --info-soft: #E7EDF7;
+
+ --shadow-sm: 0 1px 2px rgba(16,24,38,.05);
+ --shadow-md: 0 4px 16px rgba(16,24,38,.08);
+ --shadow-lg: 0 12px 32px rgba(16,24,38,.14);
+ --r-sm: 8px;
+ --r-md: 12px;
+ --r-lg: 16px;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #0C1118;
+ --surface: #141B25;
+ --surface-2: #1B2430;
+ --ink: #EAF0F6;
+ --ink-soft: #9DAAB9;
+ --ink-faint: #64707F;
+ --line: #253140;
+ --line-strong: #334154;
+
+ --brand: #29B6C2;
+ --brand-deep: #1D8E97;
+ --brand-soft: #102A2C;
+
+ --good: #3FC489;
+ --good-soft: #10281F;
+ --warn: #E3A53D;
+ --warn-soft: #2E2410;
+ --bad: #E5695D;
+ --bad-soft: #2E1714;
+ --info: #7C9CCF;
+ --info-soft: #16202F;
+
+ --shadow-sm: 0 1px 2px rgba(0,0,0,.3);
+ --shadow-md: 0 6px 20px rgba(0,0,0,.35);
+ --shadow-lg: 0 16px 40px rgba(0,0,0,.5);
+ }
+}
+
+:root[data-theme="light"] {
+ --bg: #F4F6F9; --surface: #FFFFFF; --surface-2: #EAEEF3; --ink: #101826;
+ --ink-soft: #4A5568; --ink-faint: #8896A6; --line: #E1E6ED; --line-strong: #C9D1DD;
+ --brand: #0E7C86; --brand-deep: #0A5F67; --brand-soft: #E1F2F3;
+ --good: #1F9D6B; --good-soft: #E3F6EC; --warn: #C9821D; --warn-soft: #FBF0DD;
+ --bad: #C6473D; --bad-soft: #FAE6E3; --info: #4A6FA5; --info-soft: #E7EDF7;
+}
+:root[data-theme="dark"] {
+ --bg: #0C1118; --surface: #141B25; --surface-2: #1B2430; --ink: #EAF0F6;
+ --ink-soft: #9DAAB9; --ink-faint: #64707F; --line: #253140; --line-strong: #334154;
+ --brand: #29B6C2; --brand-deep: #1D8E97; --brand-soft: #102A2C;
+ --good: #3FC489; --good-soft: #10281F; --warn: #E3A53D; --warn-soft: #2E2410;
+ --bad: #E5695D; --bad-soft: #2E1714; --info: #7C9CCF; --info-soft: #16202F;
+}
+
+* { box-sizing: border-box; }
+html, body, #app { height: 100%; }
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--ink);
+ font-family: "Cairo", "Segoe UI", Tahoma, sans-serif;
+ direction: rtl;
+ -webkit-font-smoothing: antialiased;
+}
+.num { font-family: "IBM Plex Mono", ui-monospace, monospace; font-variant-numeric: tabular-nums; direction: ltr; unicode-bidi: isolate; }
+
+a { color: inherit; }
+button { font-family: inherit; }
+
+h1, h2, h3, h4 { margin: 0; text-wrap: balance; font-weight: 700; }
+
+/* ─── Buttons ──────────────────────────────────────────── */
+.btn {
+ display: inline-flex; align-items: center; justify-content: center; gap: 8px;
+ font-family: inherit; font-size: 14px; font-weight: 600;
+ padding: 10px 18px; border-radius: var(--r-sm); border: 1px solid transparent;
+ cursor: pointer; transition: background .15s ease, border-color .15s ease, opacity .15s ease;
+ white-space: nowrap;
+}
+.btn:disabled { opacity: .5; cursor: not-allowed; }
+.btn-primary { background: var(--brand); color: white; }
+.btn-primary:hover:not(:disabled) { background: var(--brand-deep); }
+.btn-ghost { background: transparent; border-color: var(--line-strong); color: var(--ink); }
+.btn-ghost:hover:not(:disabled) { background: var(--surface-2); }
+.btn-danger { background: var(--bad); color: white; }
+.btn-danger:hover:not(:disabled) { opacity: .88; }
+.btn-sm { padding: 6px 12px; font-size: 13px; }
+
+/* ─── Form controls ────────────────────────────────────── */
+.field { display: flex; flex-direction: column; gap: 6px; }
+.field label { font-size: 13px; font-weight: 600; color: var(--ink-soft); }
+.input, select.input, textarea.input {
+ font-family: inherit; font-size: 14.5px; color: var(--ink);
+ background: var(--surface); border: 1px solid var(--line-strong); border-radius: var(--r-sm);
+ padding: 10px 12px; outline: none; transition: border-color .15s ease;
+}
+.input:focus { border-color: var(--brand); }
+.input::placeholder { color: var(--ink-faint); }
+
+/* ─── Cards / surfaces ─────────────────────────────────── */
+.card {
+ background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg);
+ box-shadow: var(--shadow-sm);
+}
+.card-pad { padding: 20px; }
+
+/* ─── Pills / badges ───────────────────────────────────── */
+.pill {
+ display: inline-flex; align-items: center; gap: 6px;
+ font-size: 12px; font-weight: 700; padding: 4px 11px; border-radius: 100px;
+ white-space: nowrap;
+}
+.pill-good { color: var(--good); background: var(--good-soft); }
+.pill-warn { color: var(--warn); background: var(--warn-soft); }
+.pill-bad { color: var(--bad); background: var(--bad-soft); }
+.pill-info { color: var(--info); background: var(--info-soft); }
+.pill-neutral { color: var(--ink-soft); background: var(--surface-2); }
+
+/* ─── Tables ────────────────────────────────────────────── */
+.table-wrap { overflow-x: auto; border-radius: var(--r-md); border: 1px solid var(--line); }
+table.tbl { width: 100%; border-collapse: collapse; font-size: 14px; background: var(--surface); }
+table.tbl th {
+ text-align: right; font-size: 12px; font-weight: 700; color: var(--ink-faint);
+ text-transform: uppercase; letter-spacing: .04em; padding: 12px 16px;
+ background: var(--surface-2); border-bottom: 1px solid var(--line);
+}
+table.tbl td { padding: 13px 16px; border-bottom: 1px solid var(--line); color: var(--ink); }
+table.tbl tr:last-child td { border-bottom: none; }
+table.tbl tbody tr:hover { background: var(--surface-2); }
+
+/* ─── Scrollbar (subtle) ───────────────────────────────── */
+::-webkit-scrollbar { width: 10px; height: 10px; }
+::-webkit-scrollbar-thumb { background: var(--line-strong); border-radius: 100px; }
+::-webkit-scrollbar-track { background: transparent; }
+
+/* ─── Focus visibility ─────────────────────────────────── */
+:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
+
+@media (prefers-reduced-motion: reduce) {
+ * { transition: none !important; animation: none !important; }
+}
diff --git a/transit_dashboard/src/utils/polyline.js b/transit_dashboard/src/utils/polyline.js
new file mode 100644
index 00000000..8d70ed1f
--- /dev/null
+++ b/transit_dashboard/src/utils/polyline.js
@@ -0,0 +1,40 @@
+// utils/polyline.js — ترميز Google Polyline القياسي (يطابق PolylineUtils.decode في تطبيقات الفلاتر)
+
+export function encodePolyline(points) {
+ let output = ''
+ let prevLat = 0
+ let prevLng = 0
+
+ for (const [lat, lng] of points) {
+ const lat5 = Math.round(lat * 1e5)
+ const lng5 = Math.round(lng * 1e5)
+ output += encodeValue(lat5 - prevLat)
+ output += encodeValue(lng5 - prevLng)
+ prevLat = lat5
+ prevLng = lng5
+ }
+ return output
+}
+
+function encodeValue(value) {
+ let v = value < 0 ? ~(value << 1) : value << 1
+ let output = ''
+ while (v >= 0x20) {
+ output += String.fromCharCode((0x20 | (v & 0x1f)) + 63)
+ v >>= 5
+ }
+ output += String.fromCharCode(v + 63)
+ return output
+}
+
+// هافرسين — مسافة بالمتر بين نقطتين
+export function haversineMeters(lat1, lng1, lat2, lng2) {
+ const R = 6371000
+ const dLat = deg2rad(lat2 - lat1)
+ const dLng = deg2rad(lng2 - lng1)
+ const a =
+ Math.sin(dLat / 2) ** 2 +
+ Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) * Math.sin(dLng / 2) ** 2
+ return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
+}
+function deg2rad(d) { return (d * Math.PI) / 180 }
diff --git a/transit_dashboard/src/views/BroadcastsView.vue b/transit_dashboard/src/views/BroadcastsView.vue
new file mode 100644
index 00000000..31aaf433
--- /dev/null
+++ b/transit_dashboard/src/views/BroadcastsView.vue
@@ -0,0 +1,112 @@
+
+
+
+
+
الإعلانات
+
+
{{ error }}
+
{{ notice }}
+
+
+
إعلان جديد
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
آخر الإعلانات المُرسلة
+
+ -
+ {{ b.title_ar || 'إعلان' }}
+ {{ b.body_ar }}
+
+
+
+
+
+
+
diff --git a/transit_dashboard/src/views/DashboardView.vue b/transit_dashboard/src/views/DashboardView.vue
new file mode 100644
index 00000000..f9dcf684
--- /dev/null
+++ b/transit_dashboard/src/views/DashboardView.vue
@@ -0,0 +1,135 @@
+
+
+
+
+
+
اليوم
+ {{ data.date }}
+
+
+
{{ error }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | الخط | الانطلاق | السائق | المركبة |
+ الحالة | التأخير | |
+
+
+
+
+ | {{ t.route_name }} |
+ {{ t.departure_time }} |
+ {{ t.driver_name }} |
+ {{ t.vehicle_plate || '—' }} |
+ {{ statusMeta[t.status]?.label || t.status }} |
+ {{ t.delay_minutes > 0 ? `+${t.delay_minutes} د` : '—' }} |
+
+
+ |
+
+ | لا توجد رحلات اليوم |
+
+
+
+
+
+
+
آخر الإعلانات
+
+ -
+ {{ b.title_ar || 'إعلان' }}
+ {{ b.body_ar }}
+
+
+
+
+
+
+
+
+
+
diff --git a/transit_dashboard/src/views/FleetView.vue b/transit_dashboard/src/views/FleetView.vue
new file mode 100644
index 00000000..db9a7b4a
--- /dev/null
+++ b/transit_dashboard/src/views/FleetView.vue
@@ -0,0 +1,207 @@
+
+
+
+
+
+
الأسطول
+
+
+
+
+
+
+
{{ error }}
+
+
+
+
+
+
+
+
+
+
+
+
+ | اللوحة | الطراز | السعة | النوع | الحالة | |
+
+
+ | {{ v.plate }} |
+ {{ [v.make, v.model, v.year].filter(Boolean).join(' ') || '—' }} |
+ {{ v.capacity }} |
+ {{ v.vehicle_type }} |
+ {{ v.is_active ? 'فعّالة' : 'معطّلة' }} |
+ |
+
+ | لا توجد مركبات بعد |
+
+
+
+
+
+
+
+
+
+
+
+
+
+ تم إرسال دعوة إلى {{ inviteResult.name }} عبر واتساب — سيفعّل حسابه من تطبيق السائق.
+
+
+
+
+
+
+
+ | الاسم | الهاتف | الحالة | |
+
+
+ | {{ d.name }} |
+ {{ d.phone }} |
+ {{ driverStatusMeta[d.status]?.label }} |
+
+
+ |
+
+ | لا يوجد سائقون بعد |
+
+
+
+
+
+
+
+
+
diff --git a/transit_dashboard/src/views/LoginView.vue b/transit_dashboard/src/views/LoginView.vue
new file mode 100644
index 00000000..ca17a3d7
--- /dev/null
+++ b/transit_dashboard/src/views/LoginView.vue
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+ مواصلاتي
+
+
لوحة مشرف المؤسسة
+
الدخول عبر رقم الهاتف المسجَّل لدى فريق سيرو
+
+
+
+
+
+
+
+
+
diff --git a/transit_dashboard/src/views/RouteEditorView.vue b/transit_dashboard/src/views/RouteEditorView.vue
new file mode 100644
index 00000000..d20034c7
--- /dev/null
+++ b/transit_dashboard/src/views/RouteEditorView.vue
@@ -0,0 +1,293 @@
+
+
+
+
+
+
+
+
{{ isNew ? 'خط جديد' : form.name_ar }}
+ {{ statusMeta[form.status]?.label }}
+
+
+
+
{{ error }}
+
{{ notice }}
+
+
+
+
بيانات الخط
+
+
+
+
+
+
+
+
+
+
+
+
المحطات والمسار
+
+
+
+
+
+
+
+
+
+
+
الجداول الزمنية
+
+
+
+ {{ s.departure_time }}
+ {{ daysSummary(s.days_mask) }}
+ {{ s.driver_name || 'سائق غير محدد' }} · {{ s.vehicle_plate || 'مركبة غير محددة' }}
+
+
+
+
لا توجد جداول بعد
+
+
+
+
+
+
+
+
+
+
diff --git a/transit_dashboard/src/views/RoutesView.vue b/transit_dashboard/src/views/RoutesView.vue
new file mode 100644
index 00000000..19fbf844
--- /dev/null
+++ b/transit_dashboard/src/views/RoutesView.vue
@@ -0,0 +1,86 @@
+
+
+
+
+
+
الخطوط
+
+
+
+
+
+
+
+
{{ error }}
+
+
+
+
+
{{ r.name_ar }}
+ {{ statusMeta[r.status]?.label }}
+
+
+ {{ r.direction === 'outbound' ? 'ذهاب' : r.direction === 'inbound' ? 'إياب' : 'دائري' }}
+ ·
+ {{ r.stop_count }} محطة
+ · {{ r.distance_km }} كم
+
+
+
لا توجد خطوط بهذه الحالة
+
+
+
+
+
diff --git a/transit_dashboard/src/views/StudentsView.vue b/transit_dashboard/src/views/StudentsView.vue
new file mode 100644
index 00000000..6f3143ad
--- /dev/null
+++ b/transit_dashboard/src/views/StudentsView.vue
@@ -0,0 +1,171 @@
+
+
+
+
+
+
الطلاب
+
+
+
+
+
+
+
{{ error }}
+
{{ notice }}
+
+
+
+
+
+
+
+
+
+ | الاسم | طريقة التحقق | الحالة | تاريخ الطلب | |
+
+
+ | {{ e.member_name || '—' }} |
+ {{ e.verify_method }} |
+ {{ statusMeta[e.status]?.label }} |
+ {{ e.created_at?.slice(0, 10) }} |
+
+
+
+ |
+ |
+
+ | لا يوجد أعضاء بهذه الحالة |
+
+
+
+
+
+
+
+
+
+
+
+
+ | الملف | الفصل | الإجمالي | مطابَق | جديد | التاريخ |
+
+
+ | {{ r.filename }} |
+ {{ r.semester || '—' }} |
+ {{ r.total_rows }} |
+ {{ r.matched_rows }} |
+ {{ r.new_enrollments }} |
+ {{ r.created_at?.slice(0, 10) }} |
+
+ | لم تُرفع كشوف بعد |
+
+
+
+
+
+
+
+
+
diff --git a/transit_dashboard/vite.config.js b/transit_dashboard/vite.config.js
new file mode 100644
index 00000000..5cf28e01
--- /dev/null
+++ b/transit_dashboard/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+
+export default defineConfig({
+ plugins: [vue()],
+ server: { port: 5183 },
+})