نسخة كاملة من مستودع سيرو عند 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>
172 lines
6.9 KiB
Vue
172 lines
6.9 KiB
Vue
<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>
|