Files
intaleq/transit_dashboard/src/views/StudentsView.vue
T
Hamza-AyedandClaude Opus 5 92dc6b3641 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>
2026-07-27 05:10:29 +03:00

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>