294 lines
12 KiB
Vue
294 lines
12 KiB
Vue
<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>
|