feat: add admin UI for reviewing pending student registrations.

Let staff list, inspect, approve, or reject enrollment and class-request submissions before final enrollment.
This commit is contained in:
2026-08-16 08:42:26 +03:30
parent e977ccd356
commit e87d78c295
7 changed files with 405 additions and 2 deletions
@@ -0,0 +1,193 @@
<!-- /src/views/pendingStudents/PendingStudentDetailView.vue -->
<template>
<div class="pending-student-detail" v-if="pending">
<PageHeader
:title="pending.user?.name || 'ثبت‌نام در انتظار'"
subtitle="بررسی پرداخت، تأیید ثبت‌نام و افزودن به کلاس"
>
<Button label="بازگشت" icon="pi pi-arrow-left" text severity="secondary" @click="$router.push('/pending-students')" />
</PageHeader>
<div class="grid">
<div class="col-12 lg:col-7">
<div class="surface-card p-4 border-round border-1 border-color shadow-sm mb-3">
<h2 class="text-lg font-bold mb-3">اطلاعات دانشپذیر</h2>
<div class="grid">
<div class="col-12 sm:col-6">
<span class="text-muted text-xs block">موبایل</span>
<span class="font-semibold" dir="ltr">{{ pending.user?.phoneNumber || '—' }}</span>
</div>
<div class="col-12 sm:col-6">
<span class="text-muted text-xs block">کد ملی</span>
<span class="font-semibold" dir="ltr">{{ pending.user?.nationalIdCode || '—' }}</span>
</div>
<div class="col-12 sm:col-6">
<span class="text-muted text-xs block">نام کاربری</span>
<span class="font-semibold" dir="ltr">{{ pending.user?.username || '—' }}</span>
</div>
<div class="col-12 sm:col-6">
<span class="text-muted text-xs block">زمان ثبت درخواست</span>
<span class="font-semibold">{{ formatJalali(pending.createdAt, 'jYYYY/jMM/jDD HH:mm') }}</span>
</div>
</div>
</div>
<div class="surface-card p-4 border-round border-1 border-color shadow-sm mb-3">
<h2 class="text-lg font-bold mb-3">کلاس و پرداخت</h2>
<div class="grid">
<div class="col-12 sm:col-6">
<span class="text-muted text-xs block">کلاس</span>
<span class="font-semibold">{{ pending.class?.name || '—' }}</span>
</div>
<div class="col-12 sm:col-6">
<span class="text-muted text-xs block">دوره</span>
<span class="font-semibold">{{ pending.course?.title || '—' }}</span>
</div>
<div class="col-12 sm:col-6">
<span class="text-muted text-xs block">نوع درخواست</span>
<span class="font-semibold">{{ pending.type === 'class_request' ? 'درخواست تشکیل کلاس' : 'ثبت‌نام' }}</span>
</div>
<div class="col-12 sm:col-6">
<span class="text-muted text-xs block">طرح پرداخت</span>
<span class="font-semibold">{{ planLabel(pending.paymentPlan) }}</span>
</div>
<div class="col-12 sm:col-6">
<span class="text-muted text-xs block">شهریه</span>
<span class="font-semibold">{{ formatPrice(pending.tuitionFee) }}</span>
</div>
<div class="col-12 sm:col-6">
<span class="text-muted text-xs block">مبلغ پرداختی</span>
<span class="font-semibold">{{ formatPrice(pending.amountDueNow) }}</span>
</div>
<div v-if="pending.paymentDiscount" class="col-12 sm:col-6">
<span class="text-muted text-xs block">تخفیف پرداخت یکجا</span>
<span class="font-semibold">{{ formatPrice(pending.paymentDiscount) }}</span>
</div>
<div v-if="pending.secondInstallmentAmount" class="col-12 sm:col-6">
<span class="text-muted text-xs block">قسط دوم</span>
<span class="font-semibold">
{{ formatPrice(pending.secondInstallmentAmount) }}
<template v-if="pending.secondInstallmentDueDate">
· {{ formatJalali(pending.secondInstallmentDueDate) }}
</template>
</span>
</div>
<div class="col-12 sm:col-6">
<span class="text-muted text-xs block">وضعیت</span>
<StatusTag :status="pending.status" type="pendingStudent" />
</div>
</div>
</div>
</div>
<div class="col-12 lg:col-5">
<div class="surface-card p-4 border-round border-1 border-color shadow-sm">
<h2 class="text-lg font-bold mb-3">اقدام مدیر</h2>
<p class="text-sm text-muted mb-3 line-height-3">
پس از تأیید پرداخت،
<template v-if="pending.type === 'enrollment'">
دانش‌پذیر به کلاس اضافه می‌شود، فاکتور ثبت می‌گردد و پیامک ثبت‌نام نهایی ارسال می‌شود.
</template>
<template v-else>
درخواست تشکیل کلاس تأیید می‌شود و پیامک اطلاع‌رسانی ارسال می‌گردد.
</template>
</p>
<div class="flex flex-column gap-2 mb-3">
<label class="font-semibold text-sm">یادداشت داخلی</label>
<Textarea v-model="adminNotes" rows="5" class="w-full text-sm" placeholder="یادداشت اختیاری…" />
</div>
<div class="flex flex-column gap-2">
<Button
label="تأیید و ثبت‌نام نهایی"
icon="pi pi-check"
:loading="approving"
@click="approve"
/>
<Button
label="رد درخواست"
icon="pi pi-times"
severity="danger"
outlined
:loading="rejecting"
@click="reject"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { pendingStudentApi } from '@/api/pendingStudentApi';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import PageHeader from '@/components/common/PageHeader.vue';
import StatusTag from '@/components/common/StatusTag.vue';
import Button from 'primevue/button';
import Textarea from 'primevue/textarea';
const route = useRoute();
const router = useRouter();
const { formatJalali } = usePersianDate();
const { showSuccess, showError } = useToast();
const pending = ref(null);
const adminNotes = ref('');
const approving = ref(false);
const rejecting = ref(false);
const planLabel = (value) => ({
full: 'پرداخت یک‌جا',
installments: 'دو قسط',
deposit: 'بیعانه (۱۰٪)'
}[value] || value);
const formatPrice = (amount) => {
if (amount == null) return '—';
return `${Number(amount).toLocaleString('fa-IR')} تومان`;
};
async function load() {
try {
const res = await pendingStudentApi.getOne(route.params.id);
pending.value = res.data?.data || res.data;
adminNotes.value = pending.value?.adminNotes || '';
} catch (error) {
showError(error?.response?.data?.error?.message || 'بارگذاری ناموفق بود');
router.push('/pending-students');
}
}
async function approve() {
approving.value = true;
try {
await pendingStudentApi.approve(route.params.id, { adminNotes: adminNotes.value });
showSuccess('درخواست تأیید شد و دانش‌پذیر ثبت‌نام گردید.');
router.push('/pending-students');
} catch (error) {
showError(error?.response?.data?.error?.message || 'تأیید ناموفق بود');
} finally {
approving.value = false;
}
}
async function reject() {
rejecting.value = true;
try {
await pendingStudentApi.reject(route.params.id, { adminNotes: adminNotes.value });
showSuccess('درخواست رد شد.');
router.push('/pending-students');
} catch (error) {
showError(error?.response?.data?.error?.message || 'رد درخواست ناموفق بود');
} finally {
rejecting.value = false;
}
}
onMounted(load);
</script>
@@ -0,0 +1,159 @@
<!-- /src/views/pendingStudents/PendingStudentListView.vue -->
<template>
<div class="pending-student-list-view">
<PageHeader title="ثبت‌نام‌های در انتظار" subtitle="بررسی پرداخت و تأیید نهایی ثبت‌نام یا درخواست کلاس">
<div class="flex flex-wrap gap-2 align-items-center">
<Dropdown
v-model="selectedStatus"
:options="statusOptions"
optionLabel="label"
optionValue="value"
placeholder="وضعیت"
showClear
class="w-12rem text-sm"
@change="onStatusFilter"
/>
<Dropdown
v-model="selectedType"
:options="typeOptions"
optionLabel="label"
optionValue="value"
placeholder="نوع درخواست"
showClear
class="w-12rem text-sm"
@change="onTypeFilter"
/>
</div>
</PageHeader>
<DataTableWrapper
:items="items"
:totalCount="totalCount"
:page="queryParams.page"
:limit="queryParams.limit"
:sortBy="queryParams.sortBy"
:sortOrder="queryParams.sortOrder"
:loading="isLoading"
@page-change="onPageChange"
@sort-change="onSort"
@search-change="onSearch"
>
<Column field="user.name" header="دانش‌پذیر">
<template #body="{ data }">
<span class="font-bold text-color">{{ data.user?.name || '—' }}</span>
<span v-if="data.user?.phoneNumber" class="text-xs text-muted block" dir="ltr">
{{ data.user.phoneNumber }}
</span>
</template>
</Column>
<Column field="class.name" header="کلاس">
<template #body="{ data }">
<span class="block">{{ data.class?.name || '—' }}</span>
<span class="text-xs text-muted">{{ data.course?.title || data.class?.course?.title || '' }}</span>
</template>
</Column>
<Column field="type" header="نوع">
<template #body="{ data }">
<Tag :value="typeLabel(data.type)" :severity="data.type === 'class_request' ? 'warning' : 'info'" class="text-xs" />
</template>
</Column>
<Column field="paymentPlan" header="طرح پرداخت">
<template #body="{ data }">
{{ planLabel(data.paymentPlan) }}
</template>
</Column>
<Column field="amountDueNow" header="مبلغ پرداختی">
<template #body="{ data }">
{{ formatPrice(data.amountDueNow) }}
</template>
</Column>
<Column field="status" header="وضعیت" sortable>
<template #body="{ data }">
<StatusTag :status="data.status" type="pendingStudent" />
</template>
</Column>
<Column field="createdAt" header="زمان ثبت" sortable>
<template #body="{ data }">
{{ formatJalali(data.createdAt, 'jYYYY/jMM/jDD HH:mm') }}
</template>
</Column>
<Column header="عملیات" style="width: 90px">
<template #body="{ data }">
<Button
icon="pi pi-eye"
text
rounded
size="small"
v-tooltip.top="'بررسی و تأیید'"
@click="$router.push(`/pending-students/view/${data._id}`)"
/>
</template>
</Column>
</DataTableWrapper>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue';
import { pendingStudentApi } from '@/api/pendingStudentApi';
import { useDataTable } from '@/composables/useDataTable';
import { usePersianDate } from '@/composables/usePersianDate';
import PageHeader from '@/components/common/PageHeader.vue';
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
import StatusTag from '@/components/common/StatusTag.vue';
import Button from 'primevue/button';
import Column from 'primevue/column';
import Dropdown from 'primevue/select';
import Tag from 'primevue/tag';
const { formatJalali } = usePersianDate();
const {
items,
totalCount,
isLoading,
queryParams,
loadData,
onPageChange,
onSort,
onSearch,
setFilter
} = useDataTable(pendingStudentApi.getAll, { sortBy: 'createdAt', sortOrder: 'desc' });
const selectedStatus = ref(null);
const selectedType = ref(null);
const statusOptions = [
{ label: 'در انتظار پرداخت', value: 'pending_payment' },
{ label: 'در انتظار بررسی', value: 'pending_review' }
];
const typeOptions = [
{ label: 'ثبت‌نام کلاس', value: 'enrollment' },
{ label: 'درخواست کلاس جدید', value: 'class_request' }
];
const typeLabel = (value) => (value === 'class_request' ? 'درخواست کلاس' : 'ثبت‌نام');
const planLabel = (value) => ({
full: 'یک‌جا',
installments: 'دو قسط',
deposit: 'بیعانه'
}[value] || value);
const formatPrice = (amount) => {
if (amount == null) return '—';
return `${Number(amount).toLocaleString('fa-IR')} تومان`;
};
const onStatusFilter = () => setFilter('status', selectedStatus.value || '');
const onTypeFilter = () => setFilter('type', selectedType.value || '');
onMounted(loadData);
</script>