Update: 2026-07-13 22:23:21

This commit is contained in:
Hamza-Ayed
2026-07-13 22:23:22 +03:00
parent fd3f4a365a
commit c6f48a1f91
45 changed files with 3533 additions and 36 deletions
@@ -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>
+207
View File
@@ -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>
+102
View File
@@ -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>