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,8 @@
|
||||
import axiosInstance from './axiosInstance';
|
||||
|
||||
export const pendingStudentApi = {
|
||||
getAll: (params) => axiosInstance.get('/pending-students/admin/get-all', { params }),
|
||||
getOne: (id) => axiosInstance.get(`/pending-students/admin/get-one/${id}`),
|
||||
approve: (id, data = {}) => axiosInstance.post(`/pending-students/admin/${id}/approve`, data),
|
||||
reject: (id, data = {}) => axiosInstance.post(`/pending-students/admin/${id}/reject`, data)
|
||||
};
|
||||
@@ -49,6 +49,14 @@ const PAYMENT_LABELS = {
|
||||
overdue: 'معوق'
|
||||
};
|
||||
|
||||
const PENDING_STUDENT_LABELS = {
|
||||
pending_payment: 'در انتظار پرداخت',
|
||||
pending_review: 'در انتظار بررسی',
|
||||
approved: 'تأییدشده',
|
||||
rejected: 'ردشده',
|
||||
cancelled: 'لغوشده'
|
||||
};
|
||||
|
||||
const severity = computed(() => {
|
||||
const val = String(props.status).toLowerCase();
|
||||
|
||||
@@ -79,6 +87,13 @@ const severity = computed(() => {
|
||||
if (val === 'failed' || val === 'ناموفق') return 'danger';
|
||||
}
|
||||
|
||||
if (props.type === 'pendingStudent') {
|
||||
if (val === 'pending_review') return 'warning';
|
||||
if (val === 'pending_payment') return 'info';
|
||||
if (val === 'approved') return 'success';
|
||||
if (val === 'rejected' || val === 'cancelled') return 'danger';
|
||||
}
|
||||
|
||||
if (val === 'true' || val === 'active' || val === 'فعال') return 'success';
|
||||
if (val === 'false' || val === 'inactive' || val === 'غیرفعال') return 'danger';
|
||||
|
||||
@@ -93,6 +108,7 @@ const label = computed(() => {
|
||||
if (props.type === 'contact') return CONTACT_LABELS[val] || raw;
|
||||
if (props.type === 'notification') return NOTIFICATION_LABELS[val] || raw;
|
||||
if (props.type === 'payment') return PAYMENT_LABELS[val] || raw;
|
||||
if (props.type === 'pendingStudent') return PENDING_STUDENT_LABELS[val] || raw;
|
||||
|
||||
if (val === 'true' || val === 'active') return 'فعال';
|
||||
if (val === 'false' || val === 'inactive') return 'غیرفعال';
|
||||
|
||||
@@ -61,7 +61,8 @@ const menuGroups = computed(() => {
|
||||
items: [
|
||||
{ label: 'امور مالی و پرداختها', icon: 'pi pi-wallet', to: '/payments' },
|
||||
{ label: 'اطلاعیهها', icon: 'pi pi-bell', to: '/notifications' },
|
||||
{ label: 'درخواستهای تماس', icon: 'pi pi-comments', to: '/contact-inquiries' }
|
||||
{ label: 'درخواستهای تماس', icon: 'pi pi-comments', to: '/contact-inquiries' },
|
||||
{ label: 'ثبتنامهای در انتظار', icon: 'pi pi-user-plus', to: '/pending-students', permission: PERMISSIONS.PENDING_STUDENTS_READ }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -74,7 +74,10 @@ export const PERMISSIONS = {
|
||||
LOGS_SEARCH: 'logs:search',
|
||||
|
||||
CONTACT_INQUIRIES_READ: 'contact_inquiries:read',
|
||||
CONTACT_INQUIRIES_UPDATE: 'contact_inquiries:update'
|
||||
CONTACT_INQUIRIES_UPDATE: 'contact_inquiries:update',
|
||||
|
||||
PENDING_STUDENTS_READ: 'pending_students:read',
|
||||
PENDING_STUDENTS_UPDATE: 'pending_students:update'
|
||||
};
|
||||
|
||||
export const PERMISSION_GROUPS = [
|
||||
@@ -229,6 +232,15 @@ export const PERMISSION_GROUPS = [
|
||||
{ key: PERMISSIONS.CONTACT_INQUIRIES_READ, label: 'مشاهده درخواستها' },
|
||||
{ key: PERMISSIONS.CONTACT_INQUIRIES_UPDATE, label: 'ویرایش / پیگیری درخواستها' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'pending_students',
|
||||
label: 'ثبتنامهای در انتظار',
|
||||
icon: 'pi pi-user-plus',
|
||||
permissions: [
|
||||
{ key: PERMISSIONS.PENDING_STUDENTS_READ, label: 'مشاهده ثبتنامهای در انتظار' },
|
||||
{ key: PERMISSIONS.PENDING_STUDENTS_UPDATE, label: 'تأیید / رد ثبتنام' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -198,6 +198,20 @@ export const routes = [
|
||||
component: () => import('@/views/contactInquiries/ContactInquiryDetailView.vue')
|
||||
},
|
||||
|
||||
// Pending student registrations
|
||||
{
|
||||
path: 'pending-students',
|
||||
name: 'PendingStudentList',
|
||||
component: () => import('@/views/pendingStudents/PendingStudentListView.vue'),
|
||||
meta: { permission: 'pending_students:read' }
|
||||
},
|
||||
{
|
||||
path: 'pending-students/view/:id',
|
||||
name: 'PendingStudentDetail',
|
||||
component: () => import('@/views/pendingStudents/PendingStudentDetailView.vue'),
|
||||
meta: { permission: 'pending_students:read' }
|
||||
},
|
||||
|
||||
{
|
||||
path: 'logs',
|
||||
name: 'LogList',
|
||||
|
||||
@@ -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