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:
@@ -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>
|
||||
Reference in New Issue
Block a user