chore: استيراد أولي من سيرو (ecfe7568) — بلا أي تعديل
نسخة كاملة من مستودع سيرو عند ecfe7568 لتكون أساس تطبيق «انطلق». نُسخ المتعقَّب في git فقط (12,509 ملفاً / 302 م.ب) بـ git archive، لا `cp -r` — فاستُثنيت تلقائياً مخلفات البناء (build · node_modules · .dart_tool · .gradle · Pods ≈ 10.7 غ.ب) وكل ما يستثنيه .gitignore. هذا الكوميت **بلا أي تعديل عمداً** حتى يكون كل ما يليه فرقاً مقروءاً مقابل سيرو الأصلي. سيرو نفسه لم يُمسّ. ⚠️ لا يبني بعد: `.env` و`lib/env/env.g.dart` غير متعقَّبين في سيرو (وهذا صحيح — أسرار لكل مستأجر). كل تطبيق فلاتر هنا يحتاج .env خاصاً بانطلق ثم توليد env.g.dart عبر build_runner. لا تُنسخ أسرار سيرو. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user