feat: add transit microservice and supervisor dashboard
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Tripz Transit Microservice Configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# --- Server Port ---
|
||||
PORT=4020
|
||||
|
||||
# --- Database Credentials (Transit) ---
|
||||
# These must match what's in docker-compose.yml under transit-db
|
||||
DB_HOST=transit-db
|
||||
DB_PORT=5432
|
||||
DB_USER=transit_user
|
||||
DB_PASSWORD=26ab87247a55f86032c943b7e361c475cd3ca8b3b772d82e2fe6a9b5ee0839a3
|
||||
DB_NAME=tripz_transit
|
||||
@@ -0,0 +1,39 @@
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
/.nyc_output
|
||||
|
||||
# IDEs and Editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.test
|
||||
.env.production
|
||||
@@ -0,0 +1,27 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
transit-db:
|
||||
image: postgres:15-alpine
|
||||
container_name: tripz-transit-db
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "5433:5432" # Exposed on 5433 to avoid conflict with main DB
|
||||
volumes:
|
||||
- transit_pgdata:/var/lib/postgresql/data
|
||||
|
||||
transit-api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: tripz-transit-api
|
||||
ports:
|
||||
- "4020:4020"
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
- transit-db
|
||||
|
||||
volumes:
|
||||
transit_pgdata:
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "backend-transit",
|
||||
"version": "1.0.0",
|
||||
"description": "Tripz Transit Microservice",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:prod": "node dist/main"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/config": "^3.0.0",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@nestjs/typeorm": "^10.0.0",
|
||||
"pg": "^8.11.0",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "^0.3.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.0.0",
|
||||
"@types/node": "^20.3.1",
|
||||
"typescript": "^5.1.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { TransitModule } from './transit/transit.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
TypeOrmModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (cfg: ConfigService) => ({
|
||||
type: 'postgres',
|
||||
host: cfg.get<string>('DB_HOST'),
|
||||
port: cfg.get<number>('DB_PORT'),
|
||||
database: cfg.get<string>('DB_NAME'),
|
||||
username: cfg.get<string>('DB_USER'),
|
||||
password: cfg.get<string>('DB_PASSWORD'),
|
||||
synchronize: true, // Should be false in production
|
||||
autoLoadEntities: true,
|
||||
}),
|
||||
}),
|
||||
TransitModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const cfg = app.get(ConfigService);
|
||||
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||
app.enableCors();
|
||||
|
||||
// Start the standalone transit API
|
||||
const port = cfg.get<number>('PORT') || 4020;
|
||||
await app.listen(port);
|
||||
console.log(`Transit Microservice running on port ${port}`);
|
||||
}
|
||||
bootstrap();
|
||||
@@ -0,0 +1,32 @@
|
||||
export class CreateRouteDto {
|
||||
name: string;
|
||||
tenant_id: string;
|
||||
polyline?: Record<string, any>;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export class CreateStationDto {
|
||||
route_id: string;
|
||||
name: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
order_index: number;
|
||||
}
|
||||
|
||||
export class CreateSupervisorDto {
|
||||
tenant_id: string;
|
||||
user_id: string;
|
||||
route_id?: string;
|
||||
institution_name?: string;
|
||||
}
|
||||
|
||||
export class StartTripDto {
|
||||
route_id: string;
|
||||
driver_id: string;
|
||||
}
|
||||
|
||||
export class BuyTicketDto {
|
||||
passenger_id: string;
|
||||
bus_trip_id: string;
|
||||
price: number;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { TransitRoute } from './transit-route.entity';
|
||||
|
||||
export type BusTripStatus = 'active' | 'completed' | 'cancelled';
|
||||
|
||||
@Entity('transit_bus_trips')
|
||||
export class TransitBusTrip {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
route_id: string;
|
||||
|
||||
// External reference to the main DB user
|
||||
@Column()
|
||||
driver_id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, default: 'active' })
|
||||
status: BusTripStatus;
|
||||
|
||||
@Column({ nullable: true })
|
||||
current_station_id: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updated_at: Date;
|
||||
|
||||
@ManyToOne(() => TransitRoute)
|
||||
@JoinColumn({ name: 'route_id' })
|
||||
route: TransitRoute;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity('transit_routes')
|
||||
export class TransitRoute {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
tenant_id: string;
|
||||
|
||||
@Column({ length: 150 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
polyline: Record<string, any>;
|
||||
|
||||
@Column({ default: true })
|
||||
is_active: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updated_at: Date;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { TransitRoute } from './transit-route.entity';
|
||||
|
||||
@Entity('transit_stations')
|
||||
export class TransitStation {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
route_id: string;
|
||||
|
||||
@Column({ length: 150 })
|
||||
name: string;
|
||||
|
||||
@Column('decimal', { precision: 10, scale: 7 })
|
||||
latitude: number;
|
||||
|
||||
@Column('decimal', { precision: 10, scale: 7 })
|
||||
longitude: number;
|
||||
|
||||
@Column('int')
|
||||
order_index: number;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
|
||||
@ManyToOne(() => TransitRoute)
|
||||
@JoinColumn({ name: 'route_id' })
|
||||
route: TransitRoute;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { TransitRoute } from './transit-route.entity';
|
||||
|
||||
@Entity('transit_supervisors')
|
||||
export class TransitSupervisor {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
tenant_id: string;
|
||||
|
||||
// External reference to the main DB user
|
||||
@Column()
|
||||
user_id: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
route_id: string;
|
||||
|
||||
@Column({ length: 150, nullable: true })
|
||||
institution_name: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
|
||||
@ManyToOne(() => TransitRoute)
|
||||
@JoinColumn({ name: 'route_id' })
|
||||
route: TransitRoute;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { TransitBusTrip } from './transit-bus-trip.entity';
|
||||
|
||||
export type TicketStatus = 'active' | 'used' | 'refunded';
|
||||
|
||||
@Entity('transit_tickets')
|
||||
export class TransitTicket {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
// External reference to the main DB user
|
||||
@Column()
|
||||
passenger_id: string;
|
||||
|
||||
@Column()
|
||||
bus_trip_id: string;
|
||||
|
||||
@Column('decimal', { precision: 10, scale: 3 })
|
||||
price: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, default: 'active' })
|
||||
status: TicketStatus;
|
||||
|
||||
@Column({ nullable: true })
|
||||
qr_code: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
|
||||
@ManyToOne(() => TransitBusTrip)
|
||||
@JoinColumn({ name: 'bus_trip_id' })
|
||||
bus_trip: TransitBusTrip;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Controller, Post, Get, Body, Param, Query } from '@nestjs/common';
|
||||
import { TransitService } from './transit.service';
|
||||
import { CreateRouteDto, CreateStationDto, CreateSupervisorDto, StartTripDto, BuyTicketDto } from './dto/transit.dto';
|
||||
|
||||
@Controller('transit')
|
||||
export class TransitController {
|
||||
constructor(private readonly transitService: TransitService) {}
|
||||
|
||||
@Post('routes')
|
||||
createRoute(@Body() dto: CreateRouteDto) {
|
||||
return this.transitService.createRoute(dto);
|
||||
}
|
||||
|
||||
@Get('routes')
|
||||
getRoutes(@Query('tenant_id') tenant_id: string) {
|
||||
return this.transitService.getRoutes(tenant_id);
|
||||
}
|
||||
|
||||
@Post('stations')
|
||||
createStation(@Body() dto: CreateStationDto) {
|
||||
return this.transitService.createStation(dto);
|
||||
}
|
||||
|
||||
@Get('routes/:routeId/stations')
|
||||
getStations(@Param('routeId') routeId: string) {
|
||||
return this.transitService.getStations(routeId);
|
||||
}
|
||||
|
||||
@Post('supervisors')
|
||||
assignSupervisor(@Body() dto: CreateSupervisorDto) {
|
||||
return this.transitService.assignSupervisor(dto);
|
||||
}
|
||||
|
||||
@Get('reports/supervisor/:userId')
|
||||
getSupervisorReports(@Param('userId') userId: string) {
|
||||
return this.transitService.getSupervisorReports(userId);
|
||||
}
|
||||
|
||||
@Post('trips/start')
|
||||
startTrip(@Body() dto: StartTripDto) {
|
||||
return this.transitService.startTrip(dto);
|
||||
}
|
||||
|
||||
@Post('trips/:id/end')
|
||||
endTrip(@Param('id') id: string) {
|
||||
return this.transitService.endTrip(id);
|
||||
}
|
||||
|
||||
@Post('tickets/buy')
|
||||
buyTicket(@Body() dto: BuyTicketDto) {
|
||||
return this.transitService.buyTicket(dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { TransitRoute } from './entities/transit-route.entity';
|
||||
import { TransitStation } from './entities/transit-station.entity';
|
||||
import { TransitSupervisor } from './entities/transit-supervisor.entity';
|
||||
import { TransitBusTrip } from './entities/transit-bus-trip.entity';
|
||||
import { TransitTicket } from './entities/transit-ticket.entity';
|
||||
import { TransitController } from './transit.controller';
|
||||
import { TransitService } from './transit.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
TransitRoute,
|
||||
TransitStation,
|
||||
TransitSupervisor,
|
||||
TransitBusTrip,
|
||||
TransitTicket,
|
||||
]),
|
||||
],
|
||||
controllers: [TransitController],
|
||||
providers: [TransitService],
|
||||
exports: [TypeOrmModule, TransitService],
|
||||
})
|
||||
export class TransitModule {}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { TransitRoute } from './entities/transit-route.entity';
|
||||
import { TransitStation } from './entities/transit-station.entity';
|
||||
import { TransitSupervisor } from './entities/transit-supervisor.entity';
|
||||
import { TransitBusTrip } from './entities/transit-bus-trip.entity';
|
||||
import { TransitTicket } from './entities/transit-ticket.entity';
|
||||
import { CreateRouteDto, CreateStationDto, CreateSupervisorDto, StartTripDto, BuyTicketDto } from './dto/transit.dto';
|
||||
|
||||
@Injectable()
|
||||
export class TransitService {
|
||||
constructor(
|
||||
@InjectRepository(TransitRoute) private routesRepo: Repository<TransitRoute>,
|
||||
@InjectRepository(TransitStation) private stationsRepo: Repository<TransitStation>,
|
||||
@InjectRepository(TransitSupervisor) private supervisorsRepo: Repository<TransitSupervisor>,
|
||||
@InjectRepository(TransitBusTrip) private tripsRepo: Repository<TransitBusTrip>,
|
||||
@InjectRepository(TransitTicket) private ticketsRepo: Repository<TransitTicket>,
|
||||
) {}
|
||||
|
||||
// Routes
|
||||
async createRoute(dto: CreateRouteDto) {
|
||||
const route = this.routesRepo.create(dto);
|
||||
return this.routesRepo.save(route);
|
||||
}
|
||||
|
||||
async getRoutes(tenant_id: string) {
|
||||
return this.routesRepo.find({ where: { tenant_id, is_active: true } });
|
||||
}
|
||||
|
||||
// Stations
|
||||
async createStation(dto: CreateStationDto) {
|
||||
const station = this.stationsRepo.create(dto);
|
||||
return this.stationsRepo.save(station);
|
||||
}
|
||||
|
||||
async getStations(route_id: string) {
|
||||
return this.stationsRepo.find({ where: { route_id }, order: { order_index: 'ASC' } });
|
||||
}
|
||||
|
||||
// Supervisors
|
||||
async assignSupervisor(dto: CreateSupervisorDto) {
|
||||
const supervisor = this.supervisorsRepo.create(dto);
|
||||
return this.supervisorsRepo.save(supervisor);
|
||||
}
|
||||
|
||||
async getSupervisorReports(user_id: string) {
|
||||
// Basic implementation: find trips and tickets for the supervisor's route
|
||||
const supervisor = await this.supervisorsRepo.findOne({ where: { user_id } });
|
||||
if (!supervisor || !supervisor.route_id) throw new NotFoundException('Supervisor or Route not found');
|
||||
|
||||
const trips = await this.tripsRepo.count({ where: { route_id: supervisor.route_id } });
|
||||
// This requires a more complex query in production, but simplified for scaffolding
|
||||
return { supervisor_id: supervisor.id, route_id: supervisor.route_id, total_trips: trips };
|
||||
}
|
||||
|
||||
// Trips
|
||||
async startTrip(dto: StartTripDto) {
|
||||
const trip = this.tripsRepo.create({ ...dto, status: 'active' });
|
||||
return this.tripsRepo.save(trip);
|
||||
}
|
||||
|
||||
async endTrip(id: string) {
|
||||
await this.tripsRepo.update(id, { status: 'completed' });
|
||||
return this.tripsRepo.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
// Tickets
|
||||
async buyTicket(dto: BuyTicketDto) {
|
||||
// Future: Call main API to deduct from wallet
|
||||
const ticket = this.ticketsRepo.create({ ...dto, status: 'active' });
|
||||
return this.ticketsRepo.save(ticket);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": false,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
*.local
|
||||
@@ -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 من الخادم.
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="ar" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>مواصلاتي — لوحة المؤسسة</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Cairo:wght@400;500;600;700;800&family=IBM+Plex+Mono:wght@500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1203
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
// api/client.js — عميل HTTP رفيع لبوابة transit/connect_admin.php
|
||||
// كل الردود بنمط {status:'success'|'failure', message: ...}
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:4020/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/json'
|
||||
payload = JSON.stringify(body || {})
|
||||
}
|
||||
|
||||
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 }
|
||||
@@ -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)
|
||||
@@ -0,0 +1,103 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const navItems = [
|
||||
{ to: '/dashboard', label: 'اليوم', icon: 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z' },
|
||||
{ to: '/fleet', label: 'الأسطول', icon: 'M3 13l1.5-5A2 2 0 0 1 6.4 6.5h11.2A2 2 0 0 1 19.5 8L21 13v6a1 1 0 0 1-1 1h-1a1 1 0 0 1-1-1v-1H6v1a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1z' },
|
||||
{ to: '/routes', label: 'الخطوط', icon: 'M9 20l-5-2V5l5 2m0 13l6-2m-6 2V7m6 11l5 2V6l-5-2m0 15V5m0 2L9 5' },
|
||||
{ to: '/students', label: 'الطلاب', icon: 'M12 3l9 4.5-9 4.5-9-4.5L12 3zM3 12l9 4.5 9-4.5M3 16.5l9 4.5 9-4.5' },
|
||||
{ to: '/broadcasts', label: 'الإعلانات', icon: 'M11 5L6 9H2v6h4l5 4V5zM19 8a5 5 0 0 1 0 8' },
|
||||
]
|
||||
|
||||
const contractLabel = computed(() => ({
|
||||
trial: 'تجريبي', active: 'نشط', suspended: 'معلَّق', terminated: 'منتهٍ',
|
||||
}[auth.org?.contract_status] || auth.org?.contract_status))
|
||||
|
||||
const contractClass = computed(() => ({
|
||||
trial: 'pill-info', active: 'pill-good', suspended: 'pill-warn', terminated: 'pill-bad',
|
||||
}[auth.org?.contract_status] || 'pill-neutral'))
|
||||
|
||||
async function doLogout() {
|
||||
await auth.logout()
|
||||
router.replace('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<span class="brand-dot"></span>
|
||||
<span>مواصلاتي</span>
|
||||
</div>
|
||||
<nav class="nav">
|
||||
<router-link v-for="item in navItems" :key="item.to" :to="item.to" class="nav-item" active-class="active">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path :d="item.icon" />
|
||||
</svg>
|
||||
<span>{{ item.label }}</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div class="main">
|
||||
<header class="topbar">
|
||||
<div class="org-info">
|
||||
<strong>{{ auth.org?.name_ar }}</strong>
|
||||
<span class="pill" :class="contractClass">{{ contractLabel }}</span>
|
||||
</div>
|
||||
<div class="account">
|
||||
<span class="admin-name">{{ auth.admin?.name }}</span>
|
||||
<button class="btn btn-ghost btn-sm" @click="doLogout">خروج</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="content">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shell { display: flex; min-height: 100vh; }
|
||||
|
||||
.sidebar {
|
||||
width: 232px; flex-shrink: 0; background: var(--surface); border-inline-start: 1px solid var(--line);
|
||||
display: flex; flex-direction: column; padding: 20px 14px;
|
||||
}
|
||||
.brand {
|
||||
display: flex; align-items: center; gap: 9px; font-weight: 800; font-size: 15.5px;
|
||||
color: var(--brand-deep); padding: 8px 10px 24px;
|
||||
}
|
||||
.brand-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--brand); }
|
||||
.nav { display: flex; flex-direction: column; gap: 3px; }
|
||||
.nav-item {
|
||||
display: flex; align-items: center; gap: 12px; padding: 11px 12px; border-radius: var(--r-sm);
|
||||
color: var(--ink-soft); text-decoration: none; font-size: 14.5px; font-weight: 600;
|
||||
transition: background .15s ease, color .15s ease;
|
||||
}
|
||||
.nav-item svg { width: 19px; height: 19px; flex-shrink: 0; }
|
||||
.nav-item:hover { background: var(--surface-2); color: var(--ink); }
|
||||
.nav-item.active { background: var(--brand-soft); color: var(--brand-deep); }
|
||||
|
||||
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
.topbar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 28px; border-bottom: 1px solid var(--line); background: var(--surface);
|
||||
}
|
||||
.org-info { display: flex; align-items: center; gap: 12px; font-size: 15px; }
|
||||
.account { display: flex; align-items: center; gap: 14px; }
|
||||
.admin-name { color: var(--ink-soft); font-size: 13.5px; }
|
||||
|
||||
.content { flex: 1; padding: 28px; max-width: 1180px; width: 100%; margin: 0 auto; }
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.sidebar { position: fixed; inset-inline-start: 0; top: 0; bottom: 0; z-index: 20; transform: translateX(100%); }
|
||||
.content { padding: 18px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
open: Boolean,
|
||||
title: String,
|
||||
body: String,
|
||||
confirmLabel: { type: String, default: 'تأكيد' },
|
||||
danger: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['confirm', 'cancel'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="overlay" @click.self="emit('cancel')">
|
||||
<div class="dialog card">
|
||||
<div class="dialog-body">
|
||||
<h3>{{ title }}</h3>
|
||||
<p>{{ body }}</p>
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<button class="btn btn-ghost" @click="emit('cancel')">إلغاء</button>
|
||||
<button :class="danger ? 'btn btn-danger' : 'btn btn-primary'" @click="emit('confirm')">
|
||||
{{ confirmLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.overlay {
|
||||
position: fixed; inset: 0; background: rgba(10,14,20,.45); backdrop-filter: blur(2px);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 100; padding: 20px;
|
||||
}
|
||||
.dialog { width: 100%; max-width: 380px; box-shadow: var(--shadow-lg); }
|
||||
.dialog-body { padding: 22px 22px 6px; }
|
||||
.dialog-body h3 { font-size: 17px; margin-bottom: 8px; }
|
||||
.dialog-body p { color: var(--ink-soft); font-size: 14px; margin: 0; }
|
||||
.dialog-actions { display: flex; justify-content: flex-end; gap: 10px; padding: 16px 22px 22px; }
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup>
|
||||
// قناع الأيام: bit0=أحد … bit6=سبت (62 = الأحد–الخميس)
|
||||
const props = defineProps({ modelValue: { type: Number, default: 62 } })
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const days = [
|
||||
{ bit: 0, label: 'أحد' },
|
||||
{ bit: 1, label: 'اثنين' },
|
||||
{ bit: 2, label: 'ثلاثاء' },
|
||||
{ bit: 3, label: 'أربعاء' },
|
||||
{ bit: 4, label: 'خميس' },
|
||||
{ bit: 5, label: 'جمعة' },
|
||||
{ bit: 6, label: 'سبت' },
|
||||
]
|
||||
|
||||
function isOn(bit) {
|
||||
return ((props.modelValue >> bit) & 1) === 1
|
||||
}
|
||||
function toggle(bit) {
|
||||
const next = isOn(bit) ? props.modelValue & ~(1 << bit) : props.modelValue | (1 << bit)
|
||||
emit('update:modelValue', next)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="day-mask">
|
||||
<button
|
||||
v-for="d in days" :key="d.bit" type="button"
|
||||
class="day-chip" :class="{ on: isOn(d.bit) }"
|
||||
@click="toggle(d.bit)"
|
||||
>
|
||||
{{ d.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.day-mask { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.day-chip {
|
||||
font-family: inherit; font-size: 13px; font-weight: 600; padding: 7px 13px;
|
||||
border-radius: 100px; border: 1px solid var(--line-strong); background: var(--surface);
|
||||
color: var(--ink-soft); cursor: pointer; transition: all .15s ease;
|
||||
}
|
||||
.day-chip.on { background: var(--brand); border-color: var(--brand); color: white; }
|
||||
.day-chip:hover:not(.on) { background: var(--surface-2); }
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import L from 'leaflet'
|
||||
import { haversineMeters } from '../utils/polyline'
|
||||
|
||||
const props = defineProps({
|
||||
stops: { type: Array, required: true }, // [{lat,lng,name_ar,name_en,radius,eta_offset_min,is_major}]
|
||||
center: { type: Array, default: () => [31.9539, 35.9106] }, // عمّان افتراضياً
|
||||
})
|
||||
const emit = defineEmits(['update:stops'])
|
||||
|
||||
const mapEl = ref(null)
|
||||
let map = null
|
||||
let markers = []
|
||||
let polyline = null
|
||||
|
||||
function iconFor(i, isMajor) {
|
||||
return L.divIcon({
|
||||
className: 'stop-marker',
|
||||
html: `<div class="stop-pin ${isMajor ? 'major' : ''}">${i + 1}</div>`,
|
||||
iconSize: [28, 28],
|
||||
iconAnchor: [14, 14],
|
||||
})
|
||||
}
|
||||
|
||||
function redraw() {
|
||||
markers.forEach((m) => m.remove())
|
||||
markers = []
|
||||
if (polyline) { polyline.remove(); polyline = null }
|
||||
|
||||
props.stops.forEach((s, i) => {
|
||||
const marker = L.marker([s.lat, s.lng], { icon: iconFor(i, s.is_major), draggable: true })
|
||||
.addTo(map)
|
||||
.bindTooltip(s.name_ar || `محطة ${i + 1}`)
|
||||
|
||||
marker.on('dragend', () => {
|
||||
const pos = marker.getLatLng()
|
||||
const next = [...props.stops]
|
||||
next[i] = { ...next[i], lat: pos.lat, lng: pos.lng }
|
||||
emit('update:stops', next)
|
||||
})
|
||||
markers.push(marker)
|
||||
})
|
||||
|
||||
if (props.stops.length > 1) {
|
||||
const latlngs = props.stops.map((s) => [s.lat, s.lng])
|
||||
polyline = L.polyline(latlngs, { color: '#0E7C86', weight: 4, opacity: 0.85 }).addTo(map)
|
||||
}
|
||||
}
|
||||
|
||||
function onMapClick(e) {
|
||||
const next = [...props.stops, {
|
||||
lat: Number(e.latlng.lat.toFixed(7)),
|
||||
lng: Number(e.latlng.lng.toFixed(7)),
|
||||
name_ar: `محطة ${props.stops.length + 1}`,
|
||||
name_en: '',
|
||||
radius: 150,
|
||||
eta_offset_min: null,
|
||||
is_major: 0,
|
||||
}]
|
||||
emit('update:stops', next)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
map = L.map(mapEl.value).setView(props.center, 13)
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap',
|
||||
maxZoom: 19,
|
||||
}).addTo(map)
|
||||
map.on('click', onMapClick)
|
||||
redraw()
|
||||
})
|
||||
onBeforeUnmount(() => { map?.remove() })
|
||||
watch(() => props.stops, redraw, { deep: true })
|
||||
|
||||
defineExpose({
|
||||
totalDistanceKm: () => {
|
||||
let d = 0
|
||||
for (let i = 1; i < props.stops.length; i++) {
|
||||
d += haversineMeters(props.stops[i - 1].lat, props.stops[i - 1].lng, props.stops[i].lat, props.stops[i].lng)
|
||||
}
|
||||
return Math.round((d / 1000) * 10) / 10
|
||||
},
|
||||
routePoints: () => props.stops.map((s) => [s.lat, s.lng]),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="map-builder">
|
||||
<div ref="mapEl" class="map-canvas"></div>
|
||||
<p class="hint">انقر على الخريطة لإضافة محطة بالترتيب — اسحب أي علامة لتعديل موقعها</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.map-builder { display: flex; flex-direction: column; gap: 8px; }
|
||||
.map-canvas { height: 420px; border-radius: var(--r-md); overflow: hidden; border: 1px solid var(--line); }
|
||||
.hint { font-size: 12.5px; color: var(--ink-faint); margin: 0; }
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* غير scoped — leaflet ينشئ العناصر خارج شجرة Vue */
|
||||
.stop-pin {
|
||||
width: 26px; height: 26px; border-radius: 50%; background: var(--brand, #0E7C86);
|
||||
color: white; display: flex; align-items: center; justify-content: center;
|
||||
font-size: 12px; font-weight: 800; border: 2px solid white;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,.3); font-family: "IBM Plex Mono", monospace;
|
||||
}
|
||||
.stop-pin.major { background: #C9821D; width: 30px; height: 30px; font-size: 13px; }
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
label: String,
|
||||
value: [String, Number],
|
||||
tone: { type: String, default: 'neutral' }, // neutral | good | warn | bad
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="stat card card-pad" :class="`tone-${tone}`">
|
||||
<div class="value num">{{ value }}</div>
|
||||
<div class="label">{{ label }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stat { display: flex; flex-direction: column; gap: 6px; }
|
||||
.value { font-size: 26px; font-weight: 800; letter-spacing: -.01em; }
|
||||
.label { font-size: 13px; color: var(--ink-faint); }
|
||||
.tone-good .value { color: var(--good); }
|
||||
.tone-warn .value { color: var(--warn); }
|
||||
.tone-bad .value { color: var(--bad); }
|
||||
</style>
|
||||
@@ -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')
|
||||
@@ -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(import.meta.env.BASE_URL), 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
|
||||
@@ -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')
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,112 @@
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import * as transitApi from '../api/transit'
|
||||
import { ApiError } from '../api/client'
|
||||
|
||||
const form = reactive({ title_ar: '', body_ar: '', target_type: 'all', target_id: '' })
|
||||
const routes = ref([])
|
||||
const recent = ref([])
|
||||
const sending = ref(false)
|
||||
const error = ref('')
|
||||
const notice = ref('')
|
||||
|
||||
async function loadRoutes() {
|
||||
try {
|
||||
const res = await transitApi.listRoutes('active')
|
||||
routes.value = res.routes
|
||||
} catch (e) { /* غير حرج */ }
|
||||
}
|
||||
|
||||
async function loadRecent() {
|
||||
try {
|
||||
const res = await transitApi.getDashboard()
|
||||
recent.value = res.recent_broadcasts
|
||||
} catch (e) { /* غير حرج */ }
|
||||
}
|
||||
|
||||
async function send() {
|
||||
error.value = ''; notice.value = ''
|
||||
if (!form.body_ar.trim()) { error.value = 'نص الإعلان مطلوب'; return }
|
||||
if (form.target_type === 'route' && !form.target_id) { error.value = 'اختر الخط المستهدَف'; return }
|
||||
|
||||
sending.value = true
|
||||
try {
|
||||
await transitApi.sendBroadcast(form)
|
||||
notice.value = 'أُرسل الإعلان بنجاح'
|
||||
form.title_ar = ''; form.body_ar = ''
|
||||
await loadRecent()
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'تعذّر إرسال الإعلان'
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { loadRoutes(); loadRecent() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="view">
|
||||
<h2>الإعلانات</h2>
|
||||
|
||||
<p v-if="error" class="error-banner">{{ error }}</p>
|
||||
<p v-if="notice" class="notice-banner">{{ notice }}</p>
|
||||
|
||||
<div class="card card-pad">
|
||||
<h3 class="section-title">إعلان جديد</h3>
|
||||
<div class="field" style="margin-bottom:14px">
|
||||
<label>العنوان (اختياري)</label>
|
||||
<input v-model="form.title_ar" class="input" placeholder="إعلان من الجامعة" />
|
||||
</div>
|
||||
<div class="field" style="margin-bottom:14px">
|
||||
<label>النص</label>
|
||||
<textarea v-model="form.body_ar" class="input" rows="4" placeholder="اكتب نص الإعلان..."></textarea>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label>الجمهور المستهدَف</label>
|
||||
<select v-model="form.target_type" class="input">
|
||||
<option value="all">كل مشتركي المؤسسة</option>
|
||||
<option value="route">مشتركو خط محدد</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" v-if="form.target_type === 'route'">
|
||||
<label>الخط</label>
|
||||
<select v-model="form.target_id" class="input">
|
||||
<option value="">اختر خطاً</option>
|
||||
<option v-for="r in routes" :key="r.id" :value="r.id">{{ r.name_ar }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" @click="send" :disabled="sending">
|
||||
{{ sending ? 'جارٍ الإرسال...' : 'إرسال الإعلان' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-pad" v-if="recent.length">
|
||||
<h3 class="section-title">آخر الإعلانات المُرسلة</h3>
|
||||
<ul class="bc-list">
|
||||
<li v-for="b in recent" :key="b.id">
|
||||
<strong>{{ b.title_ar || 'إعلان' }}</strong>
|
||||
<span class="bc-body">{{ b.body_ar }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.view { display: flex; flex-direction: column; gap: 18px; }
|
||||
.section-title { font-size: 15px; margin-bottom: 14px; }
|
||||
.grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 14px; }
|
||||
@media (max-width: 700px) { .grid-2 { grid-template-columns: 1fr; } }
|
||||
.form-actions { margin-top: 18px; display: flex; justify-content: flex-end; }
|
||||
.bc-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||
.bc-list li { display: flex; flex-direction: column; gap: 3px; padding-bottom: 12px; border-bottom: 1px solid var(--line); }
|
||||
.bc-list li:last-child { border-bottom: none; padding-bottom: 0; }
|
||||
.bc-body { color: var(--ink-soft); font-size: 13.5px; }
|
||||
.error-banner { background: var(--bad-soft); color: var(--bad); padding: 12px 16px; border-radius: var(--r-sm); font-size: 14px; }
|
||||
.notice-banner { background: var(--good-soft); color: var(--good); padding: 12px 16px; border-radius: var(--r-sm); font-size: 14px; }
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import StatCard from '../components/StatCard.vue'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
import * as transitApi from '../api/transit'
|
||||
import { ApiError } from '../api/client'
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const data = ref(null)
|
||||
let poll = null
|
||||
|
||||
const statusMeta = {
|
||||
scheduled: { label: 'مجدولة', cls: 'pill-neutral' },
|
||||
started: { label: 'منطلقة', cls: 'pill-good' },
|
||||
completed: { label: 'مكتملة', cls: 'pill-info' },
|
||||
cancelled: { label: 'ملغاة', cls: 'pill-bad' },
|
||||
no_show: { label: 'لم يحضر', cls: 'pill-warn' },
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
data.value = await transitApi.getDashboard()
|
||||
error.value = ''
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'تعذّر تحميل البيانات'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const cancelTarget = ref(null)
|
||||
async function confirmCancel() {
|
||||
try {
|
||||
await transitApi.cancelTrip(cancelTarget.value.id, 'أُلغيت من لوحة المؤسسة')
|
||||
cancelTarget.value = null
|
||||
load()
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'تعذّر إلغاء الرحلة'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
poll = setInterval(load, 15000) // تحديث كل 15 ثانية كما في المواصفة
|
||||
})
|
||||
onBeforeUnmount(() => clearInterval(poll))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="view">
|
||||
<div class="head">
|
||||
<h2>اليوم</h2>
|
||||
<span class="date num" v-if="data">{{ data.date }}</span>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error-banner">{{ error }}</p>
|
||||
|
||||
<template v-if="data">
|
||||
<div class="stats-grid">
|
||||
<StatCard label="رحلات اليوم" :value="data.stats.total_trips" />
|
||||
<StatCard label="نشطة الآن" :value="data.stats.active_now" tone="good" />
|
||||
<StatCard label="مكتملة" :value="data.stats.completed_today" />
|
||||
<StatCard label="لم يحضر السائق" :value="data.stats.no_show_today" tone="warn" />
|
||||
<StatCard label="أعضاء نشطون" :value="data.stats.active_members" />
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-wrap">
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>الخط</th><th>الانطلاق</th><th>السائق</th><th>المركبة</th>
|
||||
<th>الحالة</th><th>التأخير</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in data.trips" :key="t.id">
|
||||
<td>{{ t.route_name }}</td>
|
||||
<td class="num">{{ t.departure_time }}</td>
|
||||
<td>{{ t.driver_name }}</td>
|
||||
<td class="num">{{ t.vehicle_plate || '—' }}</td>
|
||||
<td><span class="pill" :class="statusMeta[t.status]?.cls">{{ statusMeta[t.status]?.label || t.status }}</span></td>
|
||||
<td class="num">{{ t.delay_minutes > 0 ? `+${t.delay_minutes} د` : '—' }}</td>
|
||||
<td>
|
||||
<button
|
||||
v-if="['scheduled','started'].includes(t.status)"
|
||||
class="btn btn-danger btn-sm"
|
||||
@click="cancelTarget = t"
|
||||
>إلغاء</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!data.trips.length"><td colspan="7" class="empty">لا توجد رحلات اليوم</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-pad" v-if="data.recent_broadcasts.length">
|
||||
<h3 class="section-title">آخر الإعلانات</h3>
|
||||
<ul class="bc-list">
|
||||
<li v-for="b in data.recent_broadcasts" :key="b.id">
|
||||
<strong>{{ b.title_ar || 'إعلان' }}</strong>
|
||||
<span class="bc-body">{{ b.body_ar }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="!!cancelTarget"
|
||||
title="إلغاء الرحلة"
|
||||
:body="`سيتم إلغاء رحلة خط ${cancelTarget?.route_name} وإشعار المشتركين.`"
|
||||
confirm-label="إلغاء الرحلة"
|
||||
danger
|
||||
@confirm="confirmCancel"
|
||||
@cancel="cancelTarget = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.view { display: flex; flex-direction: column; gap: 20px; }
|
||||
.head { display: flex; align-items: baseline; gap: 12px; }
|
||||
.date { color: var(--ink-faint); font-size: 14px; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 14px; }
|
||||
@media (max-width: 900px) { .stats-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||
.empty { text-align: center; color: var(--ink-faint); padding: 32px !important; }
|
||||
.section-title { font-size: 15px; margin-bottom: 14px; }
|
||||
.bc-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||
.bc-list li { display: flex; flex-direction: column; gap: 3px; padding-bottom: 12px; border-bottom: 1px solid var(--line); }
|
||||
.bc-list li:last-child { border-bottom: none; padding-bottom: 0; }
|
||||
.bc-body { color: var(--ink-soft); font-size: 13.5px; }
|
||||
.error-banner { background: var(--bad-soft); color: var(--bad); padding: 12px 16px; border-radius: var(--r-sm); font-size: 14px; }
|
||||
</style>
|
||||
@@ -0,0 +1,207 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, reactive } from 'vue'
|
||||
import * as transitApi from '../api/transit'
|
||||
import { ApiError } from '../api/client'
|
||||
|
||||
const tab = ref('vehicles') // vehicles | drivers
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
const vehicles = ref([])
|
||||
const drivers = ref([])
|
||||
|
||||
const vForm = reactive({ plate: '', make: '', model: '', year: '', color: '', capacity: 30, vehicle_type: 'bus' })
|
||||
const dForm = reactive({ name: '', phone: '', license_number: '' })
|
||||
const showVForm = ref(false)
|
||||
const showDForm = ref(false)
|
||||
const savingV = ref(false)
|
||||
const savingD = ref(false)
|
||||
const inviteResult = ref(null)
|
||||
|
||||
async function loadAll() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [v, d] = await Promise.all([transitApi.listVehicles(), transitApi.listDrivers('all')])
|
||||
vehicles.value = v.vehicles
|
||||
drivers.value = d.drivers
|
||||
error.value = ''
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'تعذّر تحميل الأسطول'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitVehicle() {
|
||||
savingV.value = true
|
||||
try {
|
||||
await transitApi.addVehicle(vForm)
|
||||
Object.assign(vForm, { plate: '', make: '', model: '', year: '', color: '', capacity: 30, vehicle_type: 'bus' })
|
||||
showVForm.value = false
|
||||
await loadAll()
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'تعذّر إضافة المركبة'
|
||||
} finally {
|
||||
savingV.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleVehicle(v) {
|
||||
try {
|
||||
await transitApi.toggleVehicle(v.id, !v.is_active)
|
||||
await loadAll()
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'تعذّر التحديث'
|
||||
}
|
||||
}
|
||||
|
||||
async function submitDriver() {
|
||||
savingD.value = true
|
||||
try {
|
||||
const res = await transitApi.inviteDriver(dForm)
|
||||
inviteResult.value = { name: dForm.name, ...res }
|
||||
Object.assign(dForm, { name: '', phone: '', license_number: '' })
|
||||
showDForm.value = false
|
||||
await loadAll()
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'تعذّر دعوة السائق'
|
||||
} finally {
|
||||
savingD.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleDriver(d) {
|
||||
const action = d.status === 'suspended' ? 'activate' : 'suspend'
|
||||
try {
|
||||
await transitApi.suspendDriver(d.id, action)
|
||||
await loadAll()
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'تعذّر التحديث'
|
||||
}
|
||||
}
|
||||
|
||||
const driverStatusMeta = {
|
||||
invited: { label: 'بانتظار التفعيل', cls: 'pill-warn' },
|
||||
active: { label: 'نشط', cls: 'pill-good' },
|
||||
suspended: { label: 'معلَّق', cls: 'pill-bad' },
|
||||
}
|
||||
|
||||
onMounted(loadAll)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="view">
|
||||
<div class="head">
|
||||
<h2>الأسطول</h2>
|
||||
<div class="tabs">
|
||||
<button class="tab" :class="{ active: tab === 'vehicles' }" @click="tab = 'vehicles'">المركبات</button>
|
||||
<button class="tab" :class="{ active: tab === 'drivers' }" @click="tab = 'drivers'">السائقون</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error-banner">{{ error }}</p>
|
||||
|
||||
<!-- ── المركبات ── -->
|
||||
<template v-if="tab === 'vehicles'">
|
||||
<div class="toolbar">
|
||||
<button class="btn btn-primary btn-sm" @click="showVForm = !showVForm">+ إضافة مركبة</button>
|
||||
</div>
|
||||
|
||||
<form v-if="showVForm" class="inline-form card card-pad" @submit.prevent="submitVehicle">
|
||||
<div class="grid-2">
|
||||
<div class="field"><label>رقم اللوحة</label><input v-model="vForm.plate" class="input" required /></div>
|
||||
<div class="field"><label>النوع</label>
|
||||
<select v-model="vForm.vehicle_type" class="input">
|
||||
<option value="bus">باص</option><option value="minibus">ميني باص</option>
|
||||
<option value="van">فان</option><option value="other">أخرى</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field"><label>الصانع</label><input v-model="vForm.make" class="input" /></div>
|
||||
<div class="field"><label>الموديل</label><input v-model="vForm.model" class="input" /></div>
|
||||
<div class="field"><label>سنة الصنع</label><input v-model="vForm.year" type="number" class="input num" /></div>
|
||||
<div class="field"><label>السعة</label><input v-model="vForm.capacity" type="number" class="input num" /></div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" :disabled="savingV">{{ savingV ? 'جارٍ الحفظ...' : 'حفظ' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-wrap">
|
||||
<table class="tbl">
|
||||
<thead><tr><th>اللوحة</th><th>الطراز</th><th>السعة</th><th>النوع</th><th>الحالة</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="v in vehicles" :key="v.id">
|
||||
<td class="num">{{ v.plate }}</td>
|
||||
<td>{{ [v.make, v.model, v.year].filter(Boolean).join(' ') || '—' }}</td>
|
||||
<td class="num">{{ v.capacity }}</td>
|
||||
<td>{{ v.vehicle_type }}</td>
|
||||
<td><span class="pill" :class="v.is_active ? 'pill-good' : 'pill-neutral'">{{ v.is_active ? 'فعّالة' : 'معطّلة' }}</span></td>
|
||||
<td><button class="btn btn-ghost btn-sm" @click="toggleVehicle(v)">{{ v.is_active ? 'تعطيل' : 'تفعيل' }}</button></td>
|
||||
</tr>
|
||||
<tr v-if="!vehicles.length"><td colspan="6" class="empty">لا توجد مركبات بعد</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── السائقون ── -->
|
||||
<template v-else>
|
||||
<div class="toolbar">
|
||||
<button class="btn btn-primary btn-sm" @click="showDForm = !showDForm">+ دعوة سائق</button>
|
||||
</div>
|
||||
|
||||
<div v-if="inviteResult" class="invite-note card card-pad">
|
||||
تم إرسال دعوة إلى <strong>{{ inviteResult.name }}</strong> عبر واتساب — سيفعّل حسابه من تطبيق السائق.
|
||||
</div>
|
||||
|
||||
<form v-if="showDForm" class="inline-form card card-pad" @submit.prevent="submitDriver">
|
||||
<div class="grid-2">
|
||||
<div class="field"><label>الاسم</label><input v-model="dForm.name" class="input" required /></div>
|
||||
<div class="field"><label>الهاتف</label><input v-model="dForm.phone" class="input num" required /></div>
|
||||
<div class="field"><label>رقم الرخصة</label><input v-model="dForm.license_number" class="input" /></div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" :disabled="savingD">{{ savingD ? 'جارٍ الإرسال...' : 'إرسال الدعوة' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-wrap">
|
||||
<table class="tbl">
|
||||
<thead><tr><th>الاسم</th><th>الهاتف</th><th>الحالة</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="d in drivers" :key="d.id">
|
||||
<td>{{ d.name }}</td>
|
||||
<td class="num">{{ d.phone }}</td>
|
||||
<td><span class="pill" :class="driverStatusMeta[d.status]?.cls">{{ driverStatusMeta[d.status]?.label }}</span></td>
|
||||
<td>
|
||||
<button v-if="d.status !== 'invited'" class="btn btn-ghost btn-sm" @click="toggleDriver(d)">
|
||||
{{ d.status === 'suspended' ? 'إعادة تفعيل' : 'تعليق' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!drivers.length"><td colspan="4" class="empty">لا يوجد سائقون بعد</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.view { display: flex; flex-direction: column; gap: 18px; }
|
||||
.head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; }
|
||||
.tabs { display: flex; gap: 4px; background: var(--surface-2); padding: 4px; border-radius: var(--r-sm); }
|
||||
.tab { font-family: inherit; border: none; background: transparent; padding: 8px 16px; border-radius: 6px; font-size: 13.5px; font-weight: 600; color: var(--ink-soft); cursor: pointer; }
|
||||
.tab.active { background: var(--surface); color: var(--ink); box-shadow: var(--shadow-sm); }
|
||||
.toolbar { display: flex; justify-content: flex-end; }
|
||||
.grid-2 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
|
||||
@media (max-width: 700px) { .grid-2 { grid-template-columns: 1fr; } }
|
||||
.form-actions { margin-top: 16px; display: flex; justify-content: flex-end; }
|
||||
.empty { text-align: center; color: var(--ink-faint); padding: 32px !important; }
|
||||
.invite-note { background: var(--brand-soft); color: var(--brand-deep); font-size: 14px; }
|
||||
.error-banner { background: var(--bad-soft); color: var(--bad); padding: 12px 16px; border-radius: var(--r-sm); font-size: 14px; }
|
||||
</style>
|
||||
@@ -0,0 +1,102 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import * as transitApi from '../api/transit'
|
||||
import { ApiError } from '../api/client'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const step = ref('phone') // phone | otp
|
||||
const phone = ref('')
|
||||
const otp = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
async function submitPhone() {
|
||||
error.value = ''
|
||||
if (!phone.value.trim()) { error.value = 'أدخل رقم الهاتف'; return }
|
||||
loading.value = true
|
||||
try {
|
||||
await transitApi.loginRequest(phone.value.trim())
|
||||
step.value = 'otp'
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'حدث خطأ، حاول مجدداً'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitOtp() {
|
||||
error.value = ''
|
||||
if (otp.value.trim().length < 3) { error.value = 'أدخل الرمز المكوّن من 3 أرقام'; return }
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.verifyOtp(phone.value.trim(), otp.value.trim())
|
||||
router.replace('/dashboard')
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'رمز غير صحيح أو منتهي'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-card card card-pad">
|
||||
<div class="brand-mark">
|
||||
<span class="brand-dot"></span>
|
||||
<span>مواصلاتي</span>
|
||||
</div>
|
||||
<h1 class="title">لوحة مشرف المؤسسة</h1>
|
||||
<p class="sub">الدخول عبر رقم الهاتف المسجَّل لدى فريق سيرو</p>
|
||||
|
||||
<form v-if="step === 'phone'" @submit.prevent="submitPhone" class="form">
|
||||
<div class="field">
|
||||
<label for="phone">رقم الهاتف</label>
|
||||
<input id="phone" v-model="phone" class="input num" type="tel" placeholder="07XXXXXXXX" autofocus />
|
||||
</div>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<button class="btn btn-primary" type="submit" :disabled="loading">
|
||||
{{ loading ? 'جارٍ الإرسال...' : 'إرسال رمز التحقق' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form v-else @submit.prevent="submitOtp" class="form">
|
||||
<div class="field">
|
||||
<label for="otp">رمز التحقق</label>
|
||||
<input id="otp" v-model="otp" class="input num" type="text" inputmode="numeric" maxlength="3" placeholder="123" autofocus />
|
||||
<span class="hint">أُرسل الرمز عبر واتساب إلى {{ phone }}</span>
|
||||
</div>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<button class="btn btn-primary" type="submit" :disabled="loading">
|
||||
{{ loading ? 'جارٍ التحقق...' : 'دخول' }}
|
||||
</button>
|
||||
<button class="btn btn-ghost" type="button" @click="step = 'phone'; otp = ''">
|
||||
تغيير رقم الهاتف
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
background: radial-gradient(120% 100% at 20% 0%, var(--brand-soft), var(--bg) 55%);
|
||||
padding: 24px;
|
||||
}
|
||||
.login-card { width: 100%; max-width: 380px; }
|
||||
.brand-mark {
|
||||
display: flex; align-items: center; gap: 8px; font-weight: 800; font-size: 15px;
|
||||
color: var(--brand-deep); margin-bottom: 28px;
|
||||
}
|
||||
.brand-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--brand); }
|
||||
.title { font-size: 22px; margin-bottom: 6px; }
|
||||
.sub { color: var(--ink-soft); font-size: 14px; margin: 0 0 26px; }
|
||||
.form { display: flex; flex-direction: column; gap: 16px; }
|
||||
.hint { font-size: 12.5px; color: var(--ink-faint); }
|
||||
.error { color: var(--bad); font-size: 13.5px; margin: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,293 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import MapRouteBuilder from '../components/MapRouteBuilder.vue'
|
||||
import DayMaskPicker from '../components/DayMaskPicker.vue'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
import * as transitApi from '../api/transit'
|
||||
import { ApiError } from '../api/client'
|
||||
import { encodePolyline } from '../utils/polyline'
|
||||
|
||||
const props = defineProps({ id: { type: String, default: null } })
|
||||
const router = useRouter()
|
||||
const isNew = computed(() => !props.id)
|
||||
|
||||
const loading = ref(!isNew.value)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const notice = ref('')
|
||||
|
||||
const form = reactive({ name_ar: '', name_en: '', direction: 'outbound', status: 'draft' })
|
||||
const stops = ref([])
|
||||
const mapRef = ref(null)
|
||||
|
||||
const schedules = ref([])
|
||||
const drivers = ref([])
|
||||
const vehicles = ref([])
|
||||
const scheduleForm = reactive({
|
||||
departure_time: '07:00', days_mask: 62, driver_id: '', vehicle_id: '',
|
||||
valid_from: new Date().toISOString().slice(0, 10), valid_until: '',
|
||||
})
|
||||
const savingSchedule = ref(false)
|
||||
const deleteScheduleTarget = ref(null)
|
||||
|
||||
const statusMeta = {
|
||||
draft: { label: 'مسودة', cls: 'pill-neutral' },
|
||||
active: { label: 'معتمد', cls: 'pill-good' },
|
||||
suspended: { label: 'موقوف', cls: 'pill-warn' },
|
||||
rejected: { label: 'مرفوض', cls: 'pill-bad' },
|
||||
}
|
||||
|
||||
function daysSummary(mask) {
|
||||
const labels = ['أحد', 'اثنين', 'ثلاثاء', 'أربعاء', 'خميس', 'جمعة', 'سبت']
|
||||
return labels.filter((_, i) => (mask >> i) & 1).join('، ')
|
||||
}
|
||||
|
||||
async function loadRoute() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await transitApi.getRoute(props.id)
|
||||
const r = res.route
|
||||
Object.assign(form, { name_ar: r.name_ar, name_en: r.name_en || '', direction: r.direction, status: r.status })
|
||||
stops.value = (r.stops || []).map((s) => ({
|
||||
lat: Number(s.latitude), lng: Number(s.longitude),
|
||||
name_ar: s.name_ar, name_en: s.name_en || '',
|
||||
radius: s.geofence_radius, eta_offset_min: s.eta_offset_min, is_major: s.is_major,
|
||||
}))
|
||||
schedules.value = r.schedules || []
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'تعذّر تحميل الخط'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFleetOptions() {
|
||||
try {
|
||||
const [d, v] = await Promise.all([transitApi.listDrivers('active'), transitApi.listVehicles()])
|
||||
drivers.value = d.drivers
|
||||
vehicles.value = v.vehicles
|
||||
} catch (e) { /* غير حرج لتحميل الصفحة */ }
|
||||
}
|
||||
|
||||
function removeStop(i) { stops.value = stops.value.filter((_, idx) => idx !== i) }
|
||||
function moveStop(i, dir) {
|
||||
const j = i + dir
|
||||
if (j < 0 || j >= stops.value.length) return
|
||||
const next = [...stops.value]
|
||||
;[next[i], next[j]] = [next[j], next[i]]
|
||||
stops.value = next
|
||||
}
|
||||
|
||||
async function saveRoute() {
|
||||
error.value = ''; notice.value = ''
|
||||
if (!form.name_ar.trim()) { error.value = 'اسم الخط مطلوب'; return }
|
||||
if (stops.value.length < 2) { error.value = 'أضف محطتين على الأقل بالنقر على الخريطة'; return }
|
||||
|
||||
saving.value = true
|
||||
const payload = {
|
||||
name_ar: form.name_ar, name_en: form.name_en, direction: form.direction,
|
||||
polyline: encodePolyline(stops.value.map((s) => [s.lat, s.lng])),
|
||||
distance_km: mapRef.value?.totalDistanceKm() ?? null,
|
||||
stops: stops.value,
|
||||
}
|
||||
try {
|
||||
if (isNew.value) {
|
||||
const res = await transitApi.addRoute(payload)
|
||||
router.replace(`/routes/${res.route_id}`)
|
||||
} else {
|
||||
payload.route_id = props.id
|
||||
const res = await transitApi.updateRoute(payload)
|
||||
form.status = res.status
|
||||
notice.value = res.status === 'draft' ? 'حُفظ الخط — بانتظار اعتماد جديد من فريق سيرو' : 'حُفظ الخط'
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'تعذّر حفظ الخط'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addSchedule() {
|
||||
savingSchedule.value = true
|
||||
try {
|
||||
await transitApi.addSchedule({ route_id: props.id, ...scheduleForm })
|
||||
const res = await transitApi.listSchedules(props.id)
|
||||
schedules.value = res.schedules
|
||||
Object.assign(scheduleForm, { departure_time: '07:00', days_mask: 62, driver_id: '', vehicle_id: '', valid_until: '' })
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'تعذّر إضافة الجدول'
|
||||
} finally {
|
||||
savingSchedule.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteSchedule() {
|
||||
try {
|
||||
await transitApi.deleteSchedule(deleteScheduleTarget.value.id)
|
||||
schedules.value = schedules.value.filter((s) => s.id !== deleteScheduleTarget.value.id)
|
||||
deleteScheduleTarget.value = null
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'تعذّر حذف الجدول'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadFleetOptions()
|
||||
if (!isNew.value) loadRoute()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="view">
|
||||
<div class="head">
|
||||
<div class="head-title">
|
||||
<button class="btn btn-ghost btn-sm" @click="router.push('/routes')">→ رجوع</button>
|
||||
<h2>{{ isNew ? 'خط جديد' : form.name_ar }}</h2>
|
||||
<span v-if="!isNew" class="pill" :class="statusMeta[form.status]?.cls">{{ statusMeta[form.status]?.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error-banner">{{ error }}</p>
|
||||
<p v-if="notice" class="notice-banner">{{ notice }}</p>
|
||||
|
||||
<div v-if="!loading" class="editor-grid">
|
||||
<div class="card card-pad">
|
||||
<h3 class="section-title">بيانات الخط</h3>
|
||||
<div class="grid-2">
|
||||
<div class="field"><label>الاسم بالعربي</label><input v-model="form.name_ar" class="input" required /></div>
|
||||
<div class="field"><label>الاسم بالإنجليزي</label><input v-model="form.name_en" class="input" /></div>
|
||||
<div class="field">
|
||||
<label>الاتجاه</label>
|
||||
<select v-model="form.direction" class="input">
|
||||
<option value="outbound">ذهاب (نحو المؤسسة)</option>
|
||||
<option value="inbound">إياب (نحو المنزل)</option>
|
||||
<option value="circular">دائري</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-pad">
|
||||
<h3 class="section-title">المحطات والمسار</h3>
|
||||
<MapRouteBuilder ref="mapRef" v-model:stops="stops" />
|
||||
|
||||
<div class="stops-list" v-if="stops.length">
|
||||
<div v-for="(s, i) in stops" :key="i" class="stop-row">
|
||||
<span class="stop-index num">{{ i + 1 }}</span>
|
||||
<input v-model="s.name_ar" class="input stop-name" placeholder="اسم المحطة" />
|
||||
<label class="major-toggle">
|
||||
<input type="checkbox" v-model="s.is_major" true-value="1" false-value="0" />
|
||||
رئيسية
|
||||
</label>
|
||||
<input v-model.number="s.radius" type="number" class="input stop-radius num" title="نصف قطر الجيوفينس (م)" />
|
||||
<div class="stop-actions">
|
||||
<button class="icon-btn" :disabled="i === 0" @click="moveStop(i, -1)" title="أعلى">↑</button>
|
||||
<button class="icon-btn" :disabled="i === stops.length - 1" @click="moveStop(i, 1)" title="أسفل">↓</button>
|
||||
<button class="icon-btn danger" @click="removeStop(i)" title="حذف">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="save-row">
|
||||
<button class="btn btn-primary" @click="saveRoute" :disabled="saving">
|
||||
{{ saving ? 'جارٍ الحفظ...' : 'حفظ الخط' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isNew" class="card card-pad">
|
||||
<h3 class="section-title">الجداول الزمنية</h3>
|
||||
|
||||
<div class="schedules-list" v-if="schedules.length">
|
||||
<div v-for="s in schedules" :key="s.id" class="schedule-row">
|
||||
<span class="num sched-time">{{ s.departure_time }}</span>
|
||||
<span class="sched-days">{{ daysSummary(s.days_mask) }}</span>
|
||||
<span class="sched-assign">{{ s.driver_name || 'سائق غير محدد' }} · {{ s.vehicle_plate || 'مركبة غير محددة' }}</span>
|
||||
<button class="icon-btn danger" @click="deleteScheduleTarget = s">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="empty-inline">لا توجد جداول بعد</p>
|
||||
|
||||
<div class="schedule-form">
|
||||
<div class="grid-2">
|
||||
<div class="field"><label>وقت الانطلاق</label><input v-model="scheduleForm.departure_time" type="time" class="input num" /></div>
|
||||
<div class="field"><label>سارٍ من</label><input v-model="scheduleForm.valid_from" type="date" class="input num" /></div>
|
||||
<div class="field"><label>السائق (اختياري)</label>
|
||||
<select v-model="scheduleForm.driver_id" class="input">
|
||||
<option value="">يُحدَّد يومياً</option>
|
||||
<option v-for="d in drivers" :key="d.id" :value="d.id">{{ d.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field"><label>المركبة (اختياري)</label>
|
||||
<select v-model="scheduleForm.vehicle_id" class="input">
|
||||
<option value="">تُحدَّد يومياً</option>
|
||||
<option v-for="v in vehicles" :key="v.id" :value="v.id">{{ v.plate }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field" style="margin-top:14px">
|
||||
<label>أيام التشغيل</label>
|
||||
<DayMaskPicker v-model="scheduleForm.days_mask" />
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary btn-sm" @click="addSchedule" :disabled="savingSchedule">
|
||||
{{ savingSchedule ? 'جارٍ الإضافة...' : '+ إضافة جدول' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="!!deleteScheduleTarget"
|
||||
title="حذف الجدول"
|
||||
body="سيتوقف إنشاء رحلات مستقبلية بهذا الجدول."
|
||||
confirm-label="حذف"
|
||||
danger
|
||||
@confirm="confirmDeleteSchedule"
|
||||
@cancel="deleteScheduleTarget = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.view { display: flex; flex-direction: column; gap: 18px; }
|
||||
.head-title { display: flex; align-items: center; gap: 12px; }
|
||||
.editor-grid { display: flex; flex-direction: column; gap: 18px; }
|
||||
.section-title { font-size: 15px; margin-bottom: 16px; }
|
||||
.grid-2 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
|
||||
@media (max-width: 700px) { .grid-2 { grid-template-columns: 1fr; } }
|
||||
|
||||
.stops-list { display: flex; flex-direction: column; gap: 8px; margin-top: 16px; }
|
||||
.stop-row { display: flex; align-items: center; gap: 10px; }
|
||||
.stop-index { width: 22px; text-align: center; color: var(--ink-faint); font-weight: 700; }
|
||||
.stop-name { flex: 1; }
|
||||
.stop-radius { width: 90px; }
|
||||
.major-toggle { display: flex; align-items: center; gap: 5px; font-size: 12.5px; color: var(--ink-soft); white-space: nowrap; }
|
||||
.stop-actions { display: flex; gap: 4px; }
|
||||
.icon-btn {
|
||||
width: 28px; height: 28px; border-radius: var(--r-sm); border: 1px solid var(--line-strong);
|
||||
background: var(--surface); color: var(--ink-soft); cursor: pointer; font-size: 13px;
|
||||
}
|
||||
.icon-btn:hover:not(:disabled) { background: var(--surface-2); }
|
||||
.icon-btn:disabled { opacity: .35; cursor: not-allowed; }
|
||||
.icon-btn.danger:hover { background: var(--bad-soft); color: var(--bad); border-color: var(--bad); }
|
||||
|
||||
.save-row { margin-top: 18px; display: flex; justify-content: flex-end; }
|
||||
|
||||
.schedules-list { display: flex; flex-direction: column; gap: 10px; margin-bottom: 18px; }
|
||||
.schedule-row {
|
||||
display: flex; align-items: center; gap: 14px; padding: 10px 14px;
|
||||
background: var(--surface-2); border-radius: var(--r-sm); font-size: 13.5px;
|
||||
}
|
||||
.sched-time { font-weight: 700; }
|
||||
.sched-days { color: var(--ink-soft); flex: 1; }
|
||||
.sched-assign { color: var(--ink-faint); font-size: 12.5px; }
|
||||
.empty-inline { color: var(--ink-faint); font-size: 13.5px; margin-bottom: 18px; }
|
||||
.schedule-form { border-top: 1px solid var(--line); padding-top: 18px; }
|
||||
.form-actions { margin-top: 16px; display: flex; justify-content: flex-end; }
|
||||
|
||||
.error-banner { background: var(--bad-soft); color: var(--bad); padding: 12px 16px; border-radius: var(--r-sm); font-size: 14px; }
|
||||
.notice-banner { background: var(--info-soft); color: var(--info); padding: 12px 16px; border-radius: var(--r-sm); font-size: 14px; }
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import * as transitApi from '../api/transit'
|
||||
import { ApiError } from '../api/client'
|
||||
|
||||
const router = useRouter()
|
||||
const filter = ref('all')
|
||||
const routes = ref([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
const statusMeta = {
|
||||
draft: { label: 'مسودة', cls: 'pill-neutral' },
|
||||
active: { label: 'معتمد', cls: 'pill-good' },
|
||||
suspended: { label: 'موقوف', cls: 'pill-warn' },
|
||||
rejected: { label: 'مرفوض', cls: 'pill-bad' },
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await transitApi.listRoutes(filter.value)
|
||||
routes.value = res.routes
|
||||
error.value = ''
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'تعذّر تحميل الخطوط'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(filter, load)
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="view">
|
||||
<div class="head">
|
||||
<h2>الخطوط</h2>
|
||||
<button class="btn btn-primary btn-sm" @click="router.push('/routes/new')">+ خط جديد</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-row">
|
||||
<select v-model="filter" class="input filter-select">
|
||||
<option value="all">كل الحالات</option>
|
||||
<option value="draft">مسودة</option>
|
||||
<option value="active">معتمد</option>
|
||||
<option value="suspended">موقوف</option>
|
||||
<option value="rejected">مرفوض</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error-banner">{{ error }}</p>
|
||||
|
||||
<div class="routes-grid">
|
||||
<div v-for="r in routes" :key="r.id" class="route-card card card-pad" @click="router.push(`/routes/${r.id}`)">
|
||||
<div class="route-top">
|
||||
<h3>{{ r.name_ar }}</h3>
|
||||
<span class="pill" :class="statusMeta[r.status]?.cls">{{ statusMeta[r.status]?.label }}</span>
|
||||
</div>
|
||||
<div class="route-meta">
|
||||
<span>{{ r.direction === 'outbound' ? 'ذهاب' : r.direction === 'inbound' ? 'إياب' : 'دائري' }}</span>
|
||||
<span>·</span>
|
||||
<span>{{ r.stop_count }} محطة</span>
|
||||
<span v-if="r.distance_km">· {{ r.distance_km }} كم</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!loading && !routes.length" class="empty-state">لا توجد خطوط بهذه الحالة</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.view { display: flex; flex-direction: column; gap: 18px; }
|
||||
.head { display: flex; align-items: center; justify-content: space-between; }
|
||||
.filter-select { max-width: 200px; }
|
||||
.routes-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; }
|
||||
.route-card { cursor: pointer; transition: box-shadow .15s ease, transform .15s ease; }
|
||||
.route-card:hover { box-shadow: var(--shadow-md); transform: translateY(-2px); }
|
||||
.route-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; margin-bottom: 8px; }
|
||||
.route-top h3 { font-size: 15.5px; }
|
||||
.route-meta { color: var(--ink-faint); font-size: 13px; display: flex; gap: 6px; }
|
||||
.empty-state { color: var(--ink-faint); grid-column: 1 / -1; text-align: center; padding: 40px; }
|
||||
.error-banner { background: var(--bad-soft); color: var(--bad); padding: 12px 16px; border-radius: var(--r-sm); font-size: 14px; }
|
||||
</style>
|
||||
@@ -0,0 +1,171 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import * as transitApi from '../api/transit'
|
||||
import { ApiError } from '../api/client'
|
||||
|
||||
const tab = ref('members') // members | rosters
|
||||
const statusFilter = ref('pending')
|
||||
const enrollments = ref([])
|
||||
const rosters = ref([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const notice = ref('')
|
||||
|
||||
const semester = ref('')
|
||||
const fileInput = ref(null)
|
||||
const uploading = ref(false)
|
||||
|
||||
const statusMeta = {
|
||||
pending: { label: 'بانتظار الموافقة', cls: 'pill-warn' },
|
||||
active: { label: 'نشط', cls: 'pill-good' },
|
||||
suspended: { label: 'معلَّق', cls: 'pill-bad' },
|
||||
expired: { label: 'منتهٍ', cls: 'pill-neutral' },
|
||||
}
|
||||
|
||||
async function loadEnrollments() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await transitApi.listEnrollments(statusFilter.value)
|
||||
enrollments.value = res.enrollments
|
||||
error.value = ''
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'تعذّر تحميل الأعضاء'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRosters() {
|
||||
try {
|
||||
const res = await transitApi.listRosters()
|
||||
rosters.value = res.rosters
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'تعذّر تحميل الكشوف'
|
||||
}
|
||||
}
|
||||
|
||||
async function decide(enrollment, action) {
|
||||
try {
|
||||
await transitApi.approveEnrollment(enrollment.id, action)
|
||||
enrollments.value = enrollments.value.filter((e) => e.id !== enrollment.id)
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'تعذّر تنفيذ الإجراء'
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadRoster() {
|
||||
const file = fileInput.value?.files?.[0]
|
||||
if (!file) { error.value = 'اختر ملف CSV أولاً'; return }
|
||||
uploading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await transitApi.importRoster(file, semester.value)
|
||||
notice.value = `تم استيراد ${res.total_rows} سجلاً — ${res.new_enrollments} جديد، ${res.matched} مطابَق مسبقاً`
|
||||
fileInput.value.value = ''
|
||||
await loadRosters()
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'تعذّر رفع الكشف'
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(statusFilter, loadEnrollments)
|
||||
onMounted(() => { loadEnrollments(); loadRosters() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="view">
|
||||
<div class="head">
|
||||
<h2>الطلاب</h2>
|
||||
<div class="tabs">
|
||||
<button class="tab" :class="{ active: tab === 'members' }" @click="tab = 'members'">الأعضاء</button>
|
||||
<button class="tab" :class="{ active: tab === 'rosters' }" @click="tab = 'rosters'">الكشوف المستوردة</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error-banner">{{ error }}</p>
|
||||
<p v-if="notice" class="notice-banner">{{ notice }}</p>
|
||||
|
||||
<template v-if="tab === 'members'">
|
||||
<div class="filter-row">
|
||||
<select v-model="statusFilter" class="input filter-select">
|
||||
<option value="pending">بانتظار الموافقة</option>
|
||||
<option value="active">نشط</option>
|
||||
<option value="suspended">معلَّق</option>
|
||||
<option value="expired">منتهٍ</option>
|
||||
<option value="all">الكل</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-wrap">
|
||||
<table class="tbl">
|
||||
<thead><tr><th>الاسم</th><th>طريقة التحقق</th><th>الحالة</th><th>تاريخ الطلب</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="e in enrollments" :key="e.id">
|
||||
<td>{{ e.member_name || '—' }}</td>
|
||||
<td>{{ e.verify_method }}</td>
|
||||
<td><span class="pill" :class="statusMeta[e.status]?.cls">{{ statusMeta[e.status]?.label }}</span></td>
|
||||
<td class="num">{{ e.created_at?.slice(0, 10) }}</td>
|
||||
<td class="row-actions" v-if="e.status === 'pending'">
|
||||
<button class="btn btn-primary btn-sm" @click="decide(e, 'approve')">قبول</button>
|
||||
<button class="btn btn-danger btn-sm" @click="decide(e, 'reject')">رفض</button>
|
||||
</td>
|
||||
<td v-else></td>
|
||||
</tr>
|
||||
<tr v-if="!enrollments.length"><td colspan="5" class="empty">لا يوجد أعضاء بهذه الحالة</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="card card-pad">
|
||||
<h3 class="section-title">رفع كشف جديد (CSV: student_id, name)</h3>
|
||||
<div class="upload-row">
|
||||
<input ref="fileInput" type="file" accept=".csv,.txt" class="input" />
|
||||
<input v-model="semester" class="input" placeholder="الفصل الدراسي (مثال: 2026-S1)" style="max-width:200px" />
|
||||
<button class="btn btn-primary" @click="uploadRoster" :disabled="uploading">
|
||||
{{ uploading ? 'جارٍ الرفع...' : 'رفع الكشف' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-wrap">
|
||||
<table class="tbl">
|
||||
<thead><tr><th>الملف</th><th>الفصل</th><th>الإجمالي</th><th>مطابَق</th><th>جديد</th><th>التاريخ</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="r in rosters" :key="r.id">
|
||||
<td>{{ r.filename }}</td>
|
||||
<td>{{ r.semester || '—' }}</td>
|
||||
<td class="num">{{ r.total_rows }}</td>
|
||||
<td class="num">{{ r.matched_rows }}</td>
|
||||
<td class="num">{{ r.new_enrollments }}</td>
|
||||
<td class="num">{{ r.created_at?.slice(0, 10) }}</td>
|
||||
</tr>
|
||||
<tr v-if="!rosters.length"><td colspan="6" class="empty">لم تُرفع كشوف بعد</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.view { display: flex; flex-direction: column; gap: 18px; }
|
||||
.head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; }
|
||||
.tabs { display: flex; gap: 4px; background: var(--surface-2); padding: 4px; border-radius: var(--r-sm); }
|
||||
.tab { font-family: inherit; border: none; background: transparent; padding: 8px 16px; border-radius: 6px; font-size: 13.5px; font-weight: 600; color: var(--ink-soft); cursor: pointer; }
|
||||
.tab.active { background: var(--surface); color: var(--ink); box-shadow: var(--shadow-sm); }
|
||||
.filter-select { max-width: 220px; }
|
||||
.row-actions { display: flex; gap: 8px; }
|
||||
.upload-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||
.section-title { font-size: 15px; margin-bottom: 14px; }
|
||||
.empty { text-align: center; color: var(--ink-faint); padding: 32px !important; }
|
||||
.error-banner { background: var(--bad-soft); color: var(--bad); padding: 12px 16px; border-radius: var(--r-sm); font-size: 14px; }
|
||||
.notice-banner { background: var(--good-soft); color: var(--good); padding: 12px 16px; border-radius: var(--r-sm); font-size: 14px; }
|
||||
</style>
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
import fs from 'fs'
|
||||
import { resolve } from 'path'
|
||||
|
||||
// تحديد مسار ملف .env ديناميكياً (حسب السيرفر أو الجهاز المحلي)
|
||||
let envDirectory = '../backend' // المسار المحلي الافتراضي
|
||||
if (fs.existsSync(resolve(__dirname, '../../../.env'))) {
|
||||
envDirectory = '../../..' // المسار الخاص بـ CloudPanel
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
base: '/transit/',
|
||||
envDir: envDirectory,
|
||||
server: { port: 5183 },
|
||||
})
|
||||
Reference in New Issue
Block a user