feat: add waitlist view, quick edit payment dialog, and catering fee in class views
This commit is contained in:
@@ -12,5 +12,7 @@ export const paymentApi = {
|
|||||||
delete: (id) => axiosInstance.delete(`/payments/admin/delete/${id}`),
|
delete: (id) => axiosInstance.delete(`/payments/admin/delete/${id}`),
|
||||||
recordTransaction: (paymentId, data) => axiosInstance.post(`/payments/admin/transactions/${paymentId}`, data),
|
recordTransaction: (paymentId, data) => axiosInstance.post(`/payments/admin/transactions/${paymentId}`, data),
|
||||||
updateTransaction: (transactionId, data) => axiosInstance.put(`/payments/admin/transactions/${transactionId}`, data),
|
updateTransaction: (transactionId, data) => axiosInstance.put(`/payments/admin/transactions/${transactionId}`, data),
|
||||||
cancelTransaction: (transactionId) => axiosInstance.post(`/payments/admin/transactions/${transactionId}/cancel`)
|
cancelTransaction: (transactionId) => axiosInstance.post(`/payments/admin/transactions/${transactionId}/cancel`),
|
||||||
|
revertTransaction: (transactionId) => axiosInstance.post(`/payments/admin/transactions/${transactionId}/revert`),
|
||||||
|
deleteTransaction: (transactionId) => axiosInstance.delete(`/payments/admin/transactions/${transactionId}`)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// /src/api/waitlistApi.js
|
||||||
|
import axiosInstance from './axiosInstance';
|
||||||
|
|
||||||
|
export const waitlistApi = {
|
||||||
|
getAll: (params) => axiosInstance.get('/waitlist/admin/get-all', { params }),
|
||||||
|
getStats: () => axiosInstance.get('/waitlist/admin/stats'),
|
||||||
|
getOne: (id) => axiosInstance.get(`/waitlist/admin/get-one/${id}`),
|
||||||
|
create: (data) => axiosInstance.post('/waitlist/admin/create', data),
|
||||||
|
update: (id, data) => axiosInstance.put(`/waitlist/admin/update/${id}`, data),
|
||||||
|
assignClass: (id, data) => axiosInstance.post(`/waitlist/admin/${id}/assign-class`, data),
|
||||||
|
revert: (id, data) => axiosInstance.post(`/waitlist/admin/${id}/revert`, data),
|
||||||
|
cancel: (id, data) => axiosInstance.post(`/waitlist/admin/${id}/cancel`, data),
|
||||||
|
delete: (id) => axiosInstance.delete(`/waitlist/admin/delete/${id}`)
|
||||||
|
};
|
||||||
@@ -47,7 +47,8 @@ const PAYMENT_LABELS = {
|
|||||||
partial: 'پیش پرداخت',
|
partial: 'پیش پرداخت',
|
||||||
pending: 'در انتظار پرداخت',
|
pending: 'در انتظار پرداخت',
|
||||||
overdue: 'معوق',
|
overdue: 'معوق',
|
||||||
cancelled: 'لغوشده'
|
cancelled: 'لغوشده',
|
||||||
|
reverted: 'مسترد شده'
|
||||||
};
|
};
|
||||||
|
|
||||||
const PENDING_STUDENT_LABELS = {
|
const PENDING_STUDENT_LABELS = {
|
||||||
@@ -67,6 +68,7 @@ const severity = computed(() => {
|
|||||||
if (val === 'pending' || val === 'در انتظار پرداخت') return 'info';
|
if (val === 'pending' || val === 'در انتظار پرداخت') return 'info';
|
||||||
if (val === 'overdue' || val === 'معوق شده' || val === 'معوق') return 'danger';
|
if (val === 'overdue' || val === 'معوق شده' || val === 'معوق') return 'danger';
|
||||||
if (val === 'cancelled' || val === 'canceled' || val === 'لغوشده') return 'secondary';
|
if (val === 'cancelled' || val === 'canceled' || val === 'لغوشده') return 'secondary';
|
||||||
|
if (val === 'reverted' || val === 'مسترد شده' || val === 'مسترد') return 'warn';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.type === 'session') {
|
if (props.type === 'session') {
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ const menuGroups = computed(() => {
|
|||||||
{ label: 'مدیریت اساتید', icon: 'pi pi-id-card', to: '/professors' },
|
{ label: 'مدیریت اساتید', icon: 'pi pi-id-card', to: '/professors' },
|
||||||
{ label: 'دورههای آموزشی', icon: 'pi pi-book', to: '/courses' },
|
{ label: 'دورههای آموزشی', icon: 'pi pi-book', to: '/courses' },
|
||||||
{ label: 'کلاسها', icon: 'pi pi-desktop', to: '/classes' },
|
{ label: 'کلاسها', icon: 'pi pi-desktop', to: '/classes' },
|
||||||
|
{ label: 'لیست انتظار', icon: 'pi pi-clock', to: '/waitlist', permission: PERMISSIONS.WAITLIST_READ },
|
||||||
{ label: 'جلسات آموزشی', icon: 'pi pi-calendar', to: '/sessions' }
|
{ label: 'جلسات آموزشی', icon: 'pi pi-calendar', to: '/sessions' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,701 @@
|
|||||||
|
<!-- /src/components/payments/QuickEditPaymentDialog.vue -->
|
||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
:visible="visible"
|
||||||
|
@update:visible="$emit('update:visible', $event)"
|
||||||
|
:header="dialogTitle"
|
||||||
|
modal
|
||||||
|
:style="{ width: '900px', maxWidth: '95vw' }"
|
||||||
|
:closable="true"
|
||||||
|
@hide="handleClose"
|
||||||
|
>
|
||||||
|
<div v-if="loading && !payment" class="py-5 text-center">
|
||||||
|
<i class="pi pi-spin pi-spinner text-3xl text-primary mb-2"></i>
|
||||||
|
<p class="text-sm text-muted">در حال دریافت اطلاعات صورتحساب…</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="payment" class="quick-edit-content flex flex-column gap-4 py-2">
|
||||||
|
<!-- Top Overview Cards -->
|
||||||
|
<div class="grid">
|
||||||
|
<div class="col-12 md:col-6 lg:col-3">
|
||||||
|
<div class="surface-100 p-3 border-round flex flex-column gap-1">
|
||||||
|
<span class="text-xs text-muted">دانشجو / کاربر</span>
|
||||||
|
<span class="font-bold text-color text-sm">{{ payment.user?.name || payment.userName || 'کاربر' }}</span>
|
||||||
|
<span v-if="payment.user?.phoneNumber" class="text-xs text-muted" dir="ltr">{{ payment.user.phoneNumber }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 md:col-6 lg:col-3">
|
||||||
|
<div class="surface-100 p-3 border-round flex flex-column gap-1">
|
||||||
|
<span class="text-xs text-muted">دوره / کلاس</span>
|
||||||
|
<div v-if="payment.classes && payment.classes.length" class="flex flex-wrap gap-1 mt-1">
|
||||||
|
<Tag v-for="c in payment.classes" :key="c._id || c" :value="c.name || 'کلاس'" severity="info" class="text-xs" />
|
||||||
|
</div>
|
||||||
|
<span v-else-if="payment.course" class="font-bold text-color text-sm">{{ payment.course?.title }}</span>
|
||||||
|
<Tag v-if="payment.type === 'waiting_list'" value="لیست انتظار" severity="warn" class="text-xs w-max mt-1" />
|
||||||
|
<span v-else-if="!payment.classes?.length && !payment.course" class="text-xs text-muted">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 md:col-6 lg:col-3">
|
||||||
|
<div class="surface-100 p-3 border-round flex flex-column gap-1">
|
||||||
|
<span class="text-xs text-muted">مبلغ قابل پرداخت / پرداختی</span>
|
||||||
|
<span class="font-bold text-color text-sm">{{ toPersianDigits(payableAmount.toLocaleString()) }} تومان</span>
|
||||||
|
<span class="text-xs text-green-600 font-semibold">
|
||||||
|
دریافتی: {{ toPersianDigits((payment.paidAmount || 0).toLocaleString()) }} تومان
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 md:col-6 lg:col-3">
|
||||||
|
<div class="surface-100 p-3 border-round flex flex-column gap-1">
|
||||||
|
<span class="text-xs text-muted">مانده / وضعیت</span>
|
||||||
|
<span class="font-bold text-sm" :class="remainingAmount > 0 ? 'text-red-500' : 'text-green-600'">
|
||||||
|
مانده: {{ toPersianDigits(remainingAmount.toLocaleString()) }} تومان
|
||||||
|
</span>
|
||||||
|
<div class="mt-1">
|
||||||
|
<StatusTag :status="payment.status" type="payment" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Payment Main Edit Section -->
|
||||||
|
<div class="p-3 border-1 border-color border-round">
|
||||||
|
<div class="flex align-items-center justify-content-between mb-3">
|
||||||
|
<h4 class="text-sm font-bold text-color m-0">مشخصات اصلی صورتحساب</h4>
|
||||||
|
<Tag v-if="payment.uniqueCode" :value="`کد: ${payment.uniqueCode}`" severity="secondary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="col-12 sm:col-6 md:col-4 flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-xs">مبلغ کل (تومان) *</label>
|
||||||
|
<InputGroup>
|
||||||
|
<InputNumber v-model="form.amount" class="w-full text-sm" :min="0" />
|
||||||
|
<InputGroupAddon>تومان</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 md:col-4 flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-xs">مبلغ تخفیف (تومان)</label>
|
||||||
|
<InputGroup>
|
||||||
|
<InputNumber v-model="form.discount" class="w-full text-sm" :min="0" :max="form.amount || 0" />
|
||||||
|
<InputGroupAddon>تومان</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 md:col-4 flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-xs">تاریخ سررسید</label>
|
||||||
|
<DatePicker v-model="form.dueDate" class="w-full text-sm" :placeholder="getTodayJalali()" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 md:col-4 flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-xs">وضعیت صورتحساب</label>
|
||||||
|
<Dropdown
|
||||||
|
v-model="form.status"
|
||||||
|
:options="paymentStatusOptions"
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
class="w-full text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 md:col-4 flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-xs">نوع صورتحساب</label>
|
||||||
|
<Dropdown
|
||||||
|
v-model="form.type"
|
||||||
|
:options="paymentTypeOptions"
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
class="w-full text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 md:col-4 flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-xs">مبلغ نهایی پس از تخفیف</label>
|
||||||
|
<div class="p-2 border-round surface-100 font-bold text-sm text-color">
|
||||||
|
{{ toPersianDigits(computedFormPayable.toLocaleString()) }} تومان
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-xs">یادداشت صورتحساب</label>
|
||||||
|
<Textarea v-model="form.notes" rows="2" class="w-full text-sm" placeholder="توضیحات و یادداشت داخلی…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-content-end gap-2 mt-3 pt-2 border-top-1 border-color">
|
||||||
|
<Button
|
||||||
|
label="ذخیره مشخصات صورتحساب"
|
||||||
|
icon="pi pi-check"
|
||||||
|
size="small"
|
||||||
|
:loading="savingPayment"
|
||||||
|
@click="handleSavePayment"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Transactions List & Management Section -->
|
||||||
|
<div class="p-3 border-1 border-color border-round">
|
||||||
|
<div class="flex align-items-center justify-content-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-bold text-color m-0">تراکنشهای صورتحساب</h4>
|
||||||
|
<span class="text-xs text-muted">تراکنشهای پرداختی، معلق، لغوشده یا مسترد شده</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
label="ثبت تراکنش جدید"
|
||||||
|
icon="pi pi-plus"
|
||||||
|
size="small"
|
||||||
|
severity="success"
|
||||||
|
@click="openAddTransactionModal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:value="payment.transactions || []"
|
||||||
|
class="p-datatable-sm text-xs"
|
||||||
|
emptyMessage="تراکنشی برای این صورتحساب ثبت نشده است"
|
||||||
|
>
|
||||||
|
<Column field="amount" header="مبلغ تراکنش">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span :class="{ 'line-through text-muted opacity-60': isTransactionCancelled(data) }" class="font-bold">
|
||||||
|
{{ toPersianDigits((data.amount || 0).toLocaleString()) }} تومان
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="status" header="وضعیت">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<StatusTag :status="data.status || (data.date ? 'paid' : 'pending')" type="payment" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="method" header="روش پرداخت">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag
|
||||||
|
v-if="data.method"
|
||||||
|
:value="data.method === 'online' ? 'آنلاین' : (data.method === 'card' ? 'کارت به کارت' : 'نقدی')"
|
||||||
|
severity="info"
|
||||||
|
class="text-xs"
|
||||||
|
/>
|
||||||
|
<span v-else class="text-muted">—</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="receiptNumber" header="شماره پیگیری">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span dir="ltr">{{ toPersianDigits(data.receiptNumber || '—') }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="dueDate" header="سررسید">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ formatJalali(data.dueDate) }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="date" header="تاریخ پرداخت">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ data.date ? formatJalali(data.date) : '—' }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="notes" header="یادداشت">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="white-space-nowrap overflow-hidden text-overflow-ellipsis block" style="max-width: 130px">
|
||||||
|
{{ data.notes || '—' }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column header="عملیات" style="width: 140px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="flex align-items-center gap-1">
|
||||||
|
<Button
|
||||||
|
v-if="!isTransactionCancelled(data)"
|
||||||
|
icon="pi pi-pencil"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
v-tooltip.top="'ویرایش تراکنش'"
|
||||||
|
@click="openEditTransactionModal(data)"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="!isTransactionCancelled(data)"
|
||||||
|
icon="pi pi-ban"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
severity="danger"
|
||||||
|
v-tooltip.top="'لغو تراکنش'"
|
||||||
|
@click="handleCancelTransaction(data)"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="!isTransactionCancelled(data)"
|
||||||
|
icon="pi pi-replay"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
severity="warn"
|
||||||
|
v-tooltip.top="'استرداد تراکنش'"
|
||||||
|
@click="handleRevertTransaction(data)"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-trash"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
severity="danger"
|
||||||
|
v-tooltip.top="'حذف تراکنش'"
|
||||||
|
@click="handleDeleteTransaction(data)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-content-between align-items-center w-full">
|
||||||
|
<Button
|
||||||
|
label="مشاهده صفحه کامل صورتحساب"
|
||||||
|
icon="pi pi-external-link"
|
||||||
|
text
|
||||||
|
size="small"
|
||||||
|
@click="navigateToFullPage"
|
||||||
|
/>
|
||||||
|
<Button label="بستن" severity="secondary" text @click="$emit('update:visible', false)" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Sub-Modal: Add Transaction -->
|
||||||
|
<Dialog v-model:visible="showAddTrxModal" header="ثبت تراکنش جدید" modal :style="{ width: '480px' }">
|
||||||
|
<div class="flex flex-column gap-3 py-2">
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">مبلغ واریزی (تومان) *</label>
|
||||||
|
<InputGroup>
|
||||||
|
<InputNumber v-model="trxAddForm.amount" class="w-full text-sm" :min="1" />
|
||||||
|
<InputGroupAddon>تومان</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">وضعیت تراکنش *</label>
|
||||||
|
<Dropdown v-model="trxAddForm.status" :options="trxStatusOptions" optionLabel="label" optionValue="value" class="w-full text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="trxAddForm.status === 'paid'" class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">روش پرداخت *</label>
|
||||||
|
<Dropdown v-model="trxAddForm.method" :options="methodOptions" optionLabel="label" optionValue="value" class="w-full text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">تاریخ سررسید *</label>
|
||||||
|
<DatePicker v-model="trxAddForm.dueDate" class="w-full text-sm" :placeholder="getTodayJalali()" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="trxAddForm.status === 'paid'" class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">تاریخ پرداخت</label>
|
||||||
|
<DatePicker v-model="trxAddForm.date" class="w-full text-sm" :placeholder="getTodayJalali()" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="trxAddForm.status === 'paid'" class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">شماره فیش / پیگیری</label>
|
||||||
|
<InputText v-model.trim="trxAddForm.receiptNumber" class="w-full text-sm" dir="ltr" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">یادداشت</label>
|
||||||
|
<Textarea v-model="trxAddForm.notes" rows="2" class="w-full text-sm" placeholder="توضیحات تراکنش…" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NotifyChannelsField :notify="trxAddNotify" />
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="انصراف" text severity="secondary" @click="showAddTrxModal = false" />
|
||||||
|
<Button label="ثبت تراکنش" icon="pi pi-check" severity="success" :loading="savingTrx" @click="submitAddTransaction" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Sub-Modal: Edit Transaction -->
|
||||||
|
<Dialog v-model:visible="showEditTrxModal" header="ویرایش تراکنش" modal :style="{ width: '480px' }">
|
||||||
|
<div class="flex flex-column gap-3 py-2" v-if="editingTrx">
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">مبلغ (تومان) *</label>
|
||||||
|
<InputGroup>
|
||||||
|
<InputNumber v-model="trxEditForm.amount" class="w-full text-sm" :min="1" />
|
||||||
|
<InputGroupAddon>تومان</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">وضعیت تراکنش</label>
|
||||||
|
<Dropdown v-model="trxEditForm.status" :options="trxFullStatusOptions" optionLabel="label" optionValue="value" class="w-full text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="trxEditForm.status === 'paid'" class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">روش پرداخت</label>
|
||||||
|
<Dropdown v-model="trxEditForm.method" :options="methodOptions" optionLabel="label" optionValue="value" class="w-full text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">تاریخ سررسید</label>
|
||||||
|
<DatePicker v-model="trxEditForm.dueDate" class="w-full text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="trxEditForm.status === 'paid'" class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">تاریخ پرداخت</label>
|
||||||
|
<DatePicker v-model="trxEditForm.date" class="w-full text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="trxEditForm.status === 'paid'" class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">شماره فیش / پیگیری</label>
|
||||||
|
<InputText v-model.trim="trxEditForm.receiptNumber" class="w-full text-sm" dir="ltr" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">یادداشت</label>
|
||||||
|
<Textarea v-model="trxEditForm.notes" rows="2" class="w-full text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="انصراف" text severity="secondary" @click="showEditTrxModal = false" />
|
||||||
|
<Button label="ذخیره تغییرات" icon="pi pi-check" :loading="savingTrx" @click="submitEditTransaction" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed, watch } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { paymentApi } from '@/api/paymentApi';
|
||||||
|
import { usePersianDate } from '@/composables/usePersianDate';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
import { getPayableAmount } from '@/utils/paymentAmount';
|
||||||
|
import Dialog from 'primevue/dialog';
|
||||||
|
import Button from 'primevue/button';
|
||||||
|
import InputText from 'primevue/inputtext';
|
||||||
|
import InputNumber from 'primevue/inputnumber';
|
||||||
|
import InputGroup from 'primevue/inputgroup';
|
||||||
|
import InputGroupAddon from 'primevue/inputgroupaddon';
|
||||||
|
import Dropdown from 'primevue/select';
|
||||||
|
import Textarea from 'primevue/textarea';
|
||||||
|
import Tag from 'primevue/tag';
|
||||||
|
import DataTable from 'primevue/datatable';
|
||||||
|
import Column from 'primevue/column';
|
||||||
|
import DatePicker from 'vue3-persian-datetime-picker';
|
||||||
|
import StatusTag from '@/components/common/StatusTag.vue';
|
||||||
|
import NotifyChannelsField from '@/components/common/NotifyChannelsField.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
paymentId: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:visible', 'updated']);
|
||||||
|
const router = useRouter();
|
||||||
|
const { showSuccess, showError } = useToast();
|
||||||
|
const { toPersianDigits, formatJalali, toJalaliPickerValue, toGregorianIso, getTodayJalali } = usePersianDate();
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const savingPayment = ref(false);
|
||||||
|
const savingTrx = ref(false);
|
||||||
|
const payment = ref(null);
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
amount: 0,
|
||||||
|
discount: 0,
|
||||||
|
dueDate: '',
|
||||||
|
status: 'pending',
|
||||||
|
type: 'regular',
|
||||||
|
notes: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const showAddTrxModal = ref(false);
|
||||||
|
const trxAddForm = reactive({
|
||||||
|
amount: null,
|
||||||
|
status: 'paid',
|
||||||
|
method: 'card',
|
||||||
|
dueDate: '',
|
||||||
|
date: '',
|
||||||
|
receiptNumber: '',
|
||||||
|
notes: ''
|
||||||
|
});
|
||||||
|
const trxAddNotify = reactive({ sms: true, email: true, bot: true });
|
||||||
|
|
||||||
|
const showEditTrxModal = ref(false);
|
||||||
|
const editingTrx = ref(null);
|
||||||
|
const trxEditForm = reactive({
|
||||||
|
amount: 0,
|
||||||
|
status: 'paid',
|
||||||
|
method: 'card',
|
||||||
|
dueDate: '',
|
||||||
|
date: '',
|
||||||
|
receiptNumber: '',
|
||||||
|
notes: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const paymentStatusOptions = [
|
||||||
|
{ label: 'در انتظار پرداخت', value: 'pending' },
|
||||||
|
{ label: 'پیش پرداخت', value: 'partial' },
|
||||||
|
{ label: 'پرداختشده (تسویه)', value: 'paid' },
|
||||||
|
{ label: 'معوق', value: 'overdue' },
|
||||||
|
{ label: 'لغو شده', value: 'cancelled' },
|
||||||
|
{ label: 'مسترد شده', value: 'reverted' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const paymentTypeOptions = [
|
||||||
|
{ label: 'عادی (کلاس)', value: 'regular' },
|
||||||
|
{ label: 'لیست انتظار', value: 'waiting_list' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const methodOptions = [
|
||||||
|
{ label: 'کارت به کارت', value: 'card' },
|
||||||
|
{ label: 'درگاه آنلاین', value: 'online' },
|
||||||
|
{ label: 'نقدی', value: 'cash' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const trxStatusOptions = [
|
||||||
|
{ label: 'پرداخت شده (تسویه)', value: 'paid' },
|
||||||
|
{ label: 'در انتظار پرداخت', value: 'pending' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const trxFullStatusOptions = [
|
||||||
|
{ label: 'پرداخت شده', value: 'paid' },
|
||||||
|
{ label: 'در انتظار پرداخت', value: 'pending' },
|
||||||
|
{ label: 'لغوشده', value: 'cancelled' },
|
||||||
|
{ label: 'مسترد شده', value: 'reverted' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const dialogTitle = computed(() => {
|
||||||
|
if (!payment.value) return 'ویرایش سریع صورتحساب';
|
||||||
|
const name = payment.value.user?.name || 'کاربر';
|
||||||
|
const code = payment.value.uniqueCode || payment.value._id;
|
||||||
|
return `ویرایش سریع صورتحساب: ${name} (${code})`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const payableAmount = computed(() => {
|
||||||
|
if (!payment.value) return 0;
|
||||||
|
return getPayableAmount(payment.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const remainingAmount = computed(() => {
|
||||||
|
if (!payment.value) return 0;
|
||||||
|
const paid = payment.value.paidAmount || 0;
|
||||||
|
return Math.max(0, payableAmount.value - paid);
|
||||||
|
});
|
||||||
|
|
||||||
|
const computedFormPayable = computed(() => {
|
||||||
|
const amt = Number(form.amount) || 0;
|
||||||
|
const disc = Number(form.discount) || 0;
|
||||||
|
return Math.max(0, amt - Math.min(disc, amt));
|
||||||
|
});
|
||||||
|
|
||||||
|
const isTransactionCancelled = (trx) => {
|
||||||
|
const s = String(trx.status || '').toLowerCase();
|
||||||
|
return s === 'cancelled' || s === 'reverted';
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchPaymentDetails = async () => {
|
||||||
|
if (!props.paymentId) return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await paymentApi.getOne(props.paymentId);
|
||||||
|
const data = res.data || res;
|
||||||
|
payment.value = data;
|
||||||
|
Object.assign(form, {
|
||||||
|
amount: data.amount || 0,
|
||||||
|
discount: data.discount || 0,
|
||||||
|
dueDate: toJalaliPickerValue(data.dueDate),
|
||||||
|
status: data.status || 'pending',
|
||||||
|
type: data.type || 'regular',
|
||||||
|
notes: data.notes || ''
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSavePayment = async () => {
|
||||||
|
if (!props.paymentId) return;
|
||||||
|
savingPayment.value = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
amount: form.amount,
|
||||||
|
discount: form.discount,
|
||||||
|
dueDate: toGregorianIso(form.dueDate),
|
||||||
|
status: form.status,
|
||||||
|
type: form.type,
|
||||||
|
notes: form.notes
|
||||||
|
};
|
||||||
|
const res = await paymentApi.update(props.paymentId, payload);
|
||||||
|
const updated = res.data || res;
|
||||||
|
payment.value = updated;
|
||||||
|
showSuccess('مشخصات صورتحساب با موفقیت ذخیره شد');
|
||||||
|
emit('updated', updated);
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
savingPayment.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAddTransactionModal = () => {
|
||||||
|
Object.assign(trxAddForm, {
|
||||||
|
amount: remainingAmount.value > 0 ? remainingAmount.value : null,
|
||||||
|
status: 'paid',
|
||||||
|
method: 'card',
|
||||||
|
dueDate: getTodayJalali(),
|
||||||
|
date: getTodayJalali(),
|
||||||
|
receiptNumber: '',
|
||||||
|
notes: ''
|
||||||
|
});
|
||||||
|
showAddTrxModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitAddTransaction = async () => {
|
||||||
|
if (!trxAddForm.amount || Number(trxAddForm.amount) <= 0) {
|
||||||
|
showError('مبلغ تراکنش باید بیشتر از صفر باشد');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
savingTrx.value = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
amount: trxAddForm.amount,
|
||||||
|
status: trxAddForm.status,
|
||||||
|
method: trxAddForm.status === 'paid' ? trxAddForm.method : undefined,
|
||||||
|
receiptNumber: trxAddForm.status === 'paid' ? trxAddForm.receiptNumber : undefined,
|
||||||
|
dueDate: toGregorianIso(trxAddForm.dueDate) || new Date(),
|
||||||
|
date: trxAddForm.status === 'paid' ? (toGregorianIso(trxAddForm.date) || new Date()) : undefined,
|
||||||
|
notes: trxAddForm.notes,
|
||||||
|
notify: { ...trxAddNotify }
|
||||||
|
};
|
||||||
|
const res = await paymentApi.recordTransaction(props.paymentId, payload);
|
||||||
|
const updated = res.data || res;
|
||||||
|
payment.value = updated;
|
||||||
|
showAddTrxModal.value = false;
|
||||||
|
showSuccess('تراکنش با موفقیت ثبت شد');
|
||||||
|
emit('updated', updated);
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
savingTrx.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditTransactionModal = (trx) => {
|
||||||
|
editingTrx.value = trx;
|
||||||
|
Object.assign(trxEditForm, {
|
||||||
|
amount: trx.amount || 0,
|
||||||
|
status: trx.status || (trx.date ? 'paid' : 'pending'),
|
||||||
|
method: trx.method || 'card',
|
||||||
|
dueDate: toJalaliPickerValue(trx.dueDate),
|
||||||
|
date: toJalaliPickerValue(trx.date),
|
||||||
|
receiptNumber: trx.receiptNumber || '',
|
||||||
|
notes: trx.notes || ''
|
||||||
|
});
|
||||||
|
showEditTrxModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitEditTransaction = async () => {
|
||||||
|
if (!editingTrx.value?._id) return;
|
||||||
|
savingTrx.value = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
amount: trxEditForm.amount,
|
||||||
|
status: trxEditForm.status,
|
||||||
|
method: trxEditForm.status === 'paid' ? trxEditForm.method : undefined,
|
||||||
|
receiptNumber: trxEditForm.status === 'paid' ? trxEditForm.receiptNumber : undefined,
|
||||||
|
dueDate: toGregorianIso(trxEditForm.dueDate),
|
||||||
|
date: trxEditForm.status === 'paid' ? toGregorianIso(trxEditForm.date) : undefined,
|
||||||
|
notes: trxEditForm.notes
|
||||||
|
};
|
||||||
|
const res = await paymentApi.updateTransaction(editingTrx.value._id, payload);
|
||||||
|
const updated = res.data || res;
|
||||||
|
payment.value = updated;
|
||||||
|
showEditTrxModal.value = false;
|
||||||
|
showSuccess('تراکنش با موفقیت ویرایش شد');
|
||||||
|
emit('updated', updated);
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
savingTrx.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelTransaction = async (trx) => {
|
||||||
|
if (!trx._id) return;
|
||||||
|
try {
|
||||||
|
const res = await paymentApi.cancelTransaction(trx._id);
|
||||||
|
const updated = res.data || res;
|
||||||
|
payment.value = updated;
|
||||||
|
showSuccess('تراکنش با موفقیت لغو شد');
|
||||||
|
emit('updated', updated);
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevertTransaction = async (trx) => {
|
||||||
|
if (!trx._id) return;
|
||||||
|
try {
|
||||||
|
const res = await paymentApi.revertTransaction(trx._id);
|
||||||
|
const updated = res.data || res;
|
||||||
|
payment.value = updated;
|
||||||
|
showSuccess('تراکنش با موفقیت مسترد شد');
|
||||||
|
emit('updated', updated);
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTransaction = async (trx) => {
|
||||||
|
if (!trx._id) return;
|
||||||
|
try {
|
||||||
|
const res = await paymentApi.deleteTransaction(trx._id);
|
||||||
|
const updated = res.data || res;
|
||||||
|
payment.value = updated;
|
||||||
|
showSuccess('تراکنش حذف شد');
|
||||||
|
emit('updated', updated);
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateToFullPage = () => {
|
||||||
|
if (payment.value?._id) {
|
||||||
|
emit('update:visible', false);
|
||||||
|
router.push(`/payments/view/${payment.value._id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
payment.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.visible, props.paymentId],
|
||||||
|
([newVisible, newId]) => {
|
||||||
|
if (newVisible && newId) {
|
||||||
|
fetchPaymentDetails();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.quick-edit-content {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -84,7 +84,12 @@ export const PERMISSIONS = {
|
|||||||
EXPENSES_UPDATE: 'expenses:update',
|
EXPENSES_UPDATE: 'expenses:update',
|
||||||
EXPENSES_DELETE: 'expenses:delete',
|
EXPENSES_DELETE: 'expenses:delete',
|
||||||
|
|
||||||
FINANCIAL_REPORTS_READ: 'financial_reports:read'
|
FINANCIAL_REPORTS_READ: 'financial_reports:read',
|
||||||
|
|
||||||
|
WAITLIST_CREATE: 'waitlist:create',
|
||||||
|
WAITLIST_READ: 'waitlist:read',
|
||||||
|
WAITLIST_UPDATE: 'waitlist:update',
|
||||||
|
WAITLIST_DELETE: 'waitlist:delete'
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PERMISSION_GROUPS = [
|
export const PERMISSION_GROUPS = [
|
||||||
@@ -138,6 +143,17 @@ export const PERMISSION_GROUPS = [
|
|||||||
{ key: PERMISSIONS.CLASSES_REGISTER_USERS, label: 'ثبتنام کاربران در کلاس' }
|
{ key: PERMISSIONS.CLASSES_REGISTER_USERS, label: 'ثبتنام کاربران در کلاس' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'waitlist',
|
||||||
|
label: 'لیست انتظار',
|
||||||
|
icon: 'pi pi-clock',
|
||||||
|
permissions: [
|
||||||
|
{ key: PERMISSIONS.WAITLIST_CREATE, label: 'افزودن به لیست انتظار' },
|
||||||
|
{ key: PERMISSIONS.WAITLIST_READ, label: 'مشاهده لیست انتظار' },
|
||||||
|
{ key: PERMISSIONS.WAITLIST_UPDATE, label: 'ویرایش / انتقال به کلاس' },
|
||||||
|
{ key: PERMISSIONS.WAITLIST_DELETE, label: 'حذف از لیست انتظار' }
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'sessions',
|
key: 'sessions',
|
||||||
label: 'جلسات آموزشی',
|
label: 'جلسات آموزشی',
|
||||||
|
|||||||
@@ -115,6 +115,14 @@ export const routes = [
|
|||||||
component: () => import('@/views/classes/ClassDetailView.vue')
|
component: () => import('@/views/classes/ClassDetailView.vue')
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Waitlist
|
||||||
|
{
|
||||||
|
path: 'waitlist',
|
||||||
|
name: 'Waitlist',
|
||||||
|
component: () => import('@/views/waitlist/WaitlistListView.vue'),
|
||||||
|
meta: { title: 'لیست انتظار', permission: 'waitlist:read' }
|
||||||
|
},
|
||||||
|
|
||||||
// Sessions
|
// Sessions
|
||||||
{
|
{
|
||||||
path: 'sessions',
|
path: 'sessions',
|
||||||
|
|||||||
@@ -35,8 +35,10 @@ export function resolveSessionDurationHours({ hoursPerSection, startTime, endTim
|
|||||||
return calculateSessionDurationHours(startTime, endTime);
|
return calculateSessionDurationHours(startTime, endTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calculatePercentageShare({ payoutPercentage = 0, revenue = 0 } = {}) {
|
export function calculatePercentageShare({ payoutPercentage = 0, revenue = 0, serviceFeePerPerson = 0, studentsCount = 0 } = {}) {
|
||||||
return (toPercentage(payoutPercentage) / 100) * toNonNegativeNumber(revenue);
|
const totalServiceFee = toNonNegativeNumber(serviceFeePerPerson) * toNonNegativeNumber(studentsCount);
|
||||||
|
const netRevenue = Math.max(0, toNonNegativeNumber(revenue) - totalServiceFee);
|
||||||
|
return (toPercentage(payoutPercentage) / 100) * netRevenue;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calculateHourlyShare({ payoutHourlyRate = 0, sessionDurationHours = 0, sessionsCount = 0 } = {}) {
|
export function calculateHourlyShare({ payoutHourlyRate = 0, sessionDurationHours = 0, sessionsCount = 0 } = {}) {
|
||||||
|
|||||||
@@ -47,6 +47,19 @@ describe('calculateProfessorPayout', () => {
|
|||||||
assert.equal(result.totalPayout, 4_500_000);
|
assert.equal(result.totalPayout, 4_500_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('computes percentage-based payout deducting serviceFeePerPerson * studentsCount first', () => {
|
||||||
|
const result = calculateProfessorPayout({
|
||||||
|
payoutType: 'percentage',
|
||||||
|
payoutPercentage: 50,
|
||||||
|
revenue: 20_000_000,
|
||||||
|
serviceFeePerPerson: 400_000,
|
||||||
|
studentsCount: 2
|
||||||
|
});
|
||||||
|
// 20M - (400k * 2) = 19.2M * 50% = 9.6M
|
||||||
|
assert.equal(result.baseShare, 9_600_000);
|
||||||
|
assert.equal(result.totalPayout, 9_600_000);
|
||||||
|
});
|
||||||
|
|
||||||
it('computes hourly-based payout with extra expenses', () => {
|
it('computes hourly-based payout with extra expenses', () => {
|
||||||
const result = calculateProfessorPayout({
|
const result = calculateProfessorPayout({
|
||||||
payoutType: 'hourly',
|
payoutType: 'hourly',
|
||||||
|
|||||||
@@ -67,6 +67,10 @@
|
|||||||
<span class="text-muted text-xs block mb-1">ساعت کلاس</span>
|
<span class="text-muted text-xs block mb-1">ساعت کلاس</span>
|
||||||
<span class="font-bold text-color text-sm" dir="ltr">{{ formatClassTime(classData.startTime, classData.endTime) || '—' }}</span>
|
<span class="font-bold text-color text-sm" dir="ltr">{{ formatClassTime(classData.startTime, classData.endTime) || '—' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-12 sm:col-4" v-if="classData.serviceFeePerPerson">
|
||||||
|
<span class="text-muted text-xs block mb-1">هزینه پذیرایی به ازای هر نفر</span>
|
||||||
|
<span class="font-bold text-color text-sm">{{ toPersianDigits((classData.serviceFeePerPerson || 0).toLocaleString('en-US')) }} تومان</span>
|
||||||
|
</div>
|
||||||
<div class="col-12 sm:col-4">
|
<div class="col-12 sm:col-4">
|
||||||
<span class="text-muted text-xs block mb-1">وضعیت</span>
|
<span class="text-muted text-xs block mb-1">وضعیت</span>
|
||||||
<StatusTag :status="classData.isActive !== false" />
|
<StatusTag :status="classData.isActive !== false" />
|
||||||
|
|||||||
@@ -107,7 +107,15 @@
|
|||||||
<div class="col-12 md:col-4 flex flex-column gap-2">
|
<div class="col-12 md:col-4 flex flex-column gap-2">
|
||||||
<label class="font-semibold text-sm">هزینه جانبی هر جلسه</label>
|
<label class="font-semibold text-sm">هزینه جانبی هر جلسه</label>
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
<InputNumber v-model="form.extraExpensePerSession" :min="0" class="w-full text-sm" placeholder="مثلا: رفتوآمد، پذیرایی" />
|
<InputNumber v-model="form.extraExpensePerSession" :min="0" class="w-full text-sm" placeholder="مثلا: رفتوآمد" />
|
||||||
|
<InputGroupAddon>تومان</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 md:col-4 flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">هزینه پذیرایی به ازای هر نفر</label>
|
||||||
|
<InputGroup>
|
||||||
|
<InputNumber v-model="form.serviceFeePerPerson" :min="0" class="w-full text-sm" placeholder="کسر از شهریه قبل از درصد" />
|
||||||
<InputGroupAddon>تومان</InputGroupAddon>
|
<InputGroupAddon>تومان</InputGroupAddon>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
</div>
|
</div>
|
||||||
@@ -123,6 +131,9 @@
|
|||||||
سهم تخمینی هر جلسه: <strong class="text-color">{{ toPersianDigits(sessionShareEstimate.toLocaleString()) }} تومان</strong>
|
سهم تخمینی هر جلسه: <strong class="text-color">{{ toPersianDigits(sessionShareEstimate.toLocaleString()) }} تومان</strong>
|
||||||
(نرخ ساعتی × مدت جلسه)
|
(نرخ ساعتی × مدت جلسه)
|
||||||
</span>
|
</span>
|
||||||
|
<span class="text-xs text-muted" v-if="form.payoutType === 'percentage' && form.serviceFeePerPerson">
|
||||||
|
هزینه پذیرایی ({{ toPersianDigits((form.serviceFeePerPerson || 0).toLocaleString()) }} تومان به ازای هر نفر) پیش از محاسبه درصد سهم استاد، از شهریه کسر خواهد شد.
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -380,6 +391,7 @@ const form = reactive({
|
|||||||
payoutPercentage: 0,
|
payoutPercentage: 0,
|
||||||
payoutHourlyRate: 0,
|
payoutHourlyRate: 0,
|
||||||
extraExpensePerSession: 0,
|
extraExpensePerSession: 0,
|
||||||
|
serviceFeePerPerson: 0,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
adminNotes: []
|
adminNotes: []
|
||||||
});
|
});
|
||||||
@@ -505,6 +517,7 @@ const fetchData = async () => {
|
|||||||
payoutPercentage: data.payoutPercentage || 0,
|
payoutPercentage: data.payoutPercentage || 0,
|
||||||
payoutHourlyRate: data.payoutHourlyRate || 0,
|
payoutHourlyRate: data.payoutHourlyRate || 0,
|
||||||
extraExpensePerSession: data.extraExpensePerSession || 0,
|
extraExpensePerSession: data.extraExpensePerSession || 0,
|
||||||
|
serviceFeePerPerson: data.serviceFeePerPerson || 0,
|
||||||
isActive: data.isActive !== false,
|
isActive: data.isActive !== false,
|
||||||
adminNotes: Array.isArray(data.adminNotes) ? [...data.adminNotes] : []
|
adminNotes: Array.isArray(data.adminNotes) ? [...data.adminNotes] : []
|
||||||
});
|
});
|
||||||
@@ -560,6 +573,7 @@ const handleSubmit = async () => {
|
|||||||
payoutPercentage: form.payoutType === 'percentage' ? form.payoutPercentage : 0,
|
payoutPercentage: form.payoutType === 'percentage' ? form.payoutPercentage : 0,
|
||||||
payoutHourlyRate: form.payoutType === 'hourly' ? form.payoutHourlyRate : 0,
|
payoutHourlyRate: form.payoutType === 'hourly' ? form.payoutHourlyRate : 0,
|
||||||
extraExpensePerSession: form.extraExpensePerSession,
|
extraExpensePerSession: form.extraExpensePerSession,
|
||||||
|
serviceFeePerPerson: form.serviceFeePerPerson,
|
||||||
isActive: form.isActive,
|
isActive: form.isActive,
|
||||||
adminNotes: (form.adminNotes || []).map((n) => String(n).trim()).filter(Boolean)
|
adminNotes: (form.adminNotes || []).map((n) => String(n).trim()).filter(Boolean)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -65,9 +65,21 @@
|
|||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column header="عملیات" style="width: 110px">
|
<Column header="عملیات" style="width: 140px">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<div class="flex align-items-center gap-1">
|
<div class="flex align-items-center gap-1">
|
||||||
|
<PermissionGate permission="payments:update">
|
||||||
|
<Button
|
||||||
|
icon="pi pi-pencil"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
v-tooltip.top="'ویرایش سریع'"
|
||||||
|
@click="openQuickEdit(data)"
|
||||||
|
/>
|
||||||
|
</PermissionGate>
|
||||||
|
|
||||||
<PermissionGate permission="payments:read">
|
<PermissionGate permission="payments:read">
|
||||||
<Button icon="pi pi-eye" text rounded size="small" v-tooltip.top="'مشاهده'" @click="$router.push(`/payments/view/${data._id || data.id}`)" />
|
<Button icon="pi pi-eye" text rounded size="small" v-tooltip.top="'مشاهده'" @click="$router.push(`/payments/view/${data._id || data.id}`)" />
|
||||||
</PermissionGate>
|
</PermissionGate>
|
||||||
@@ -294,6 +306,12 @@
|
|||||||
:loading="isDeleting"
|
:loading="isDeleting"
|
||||||
@confirm="handleDelete"
|
@confirm="handleDelete"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<QuickEditPaymentDialog
|
||||||
|
v-model:visible="quickEditVisible"
|
||||||
|
:payment-id="quickEditPaymentId"
|
||||||
|
@updated="loadData"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -312,6 +330,7 @@ import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
|
|||||||
import PermissionGate from '@/components/common/PermissionGate.vue';
|
import PermissionGate from '@/components/common/PermissionGate.vue';
|
||||||
import StatusTag from '@/components/common/StatusTag.vue';
|
import StatusTag from '@/components/common/StatusTag.vue';
|
||||||
import NotifyChannelsField from '@/components/common/NotifyChannelsField.vue';
|
import NotifyChannelsField from '@/components/common/NotifyChannelsField.vue';
|
||||||
|
import QuickEditPaymentDialog from '@/components/payments/QuickEditPaymentDialog.vue';
|
||||||
import { getPayableAmount } from '@/utils/paymentAmount';
|
import { getPayableAmount } from '@/utils/paymentAmount';
|
||||||
import { calculateClassMidDate } from '@/utils/classSchedule';
|
import { calculateClassMidDate } from '@/utils/classSchedule';
|
||||||
import Button from 'primevue/button';
|
import Button from 'primevue/button';
|
||||||
@@ -349,6 +368,14 @@ const isCreating = ref(false);
|
|||||||
const duplicateWarning = ref(null);
|
const duplicateWarning = ref(null);
|
||||||
let lastDuplicateAlertKey = '';
|
let lastDuplicateAlertKey = '';
|
||||||
|
|
||||||
|
const quickEditVisible = ref(false);
|
||||||
|
const quickEditPaymentId = ref('');
|
||||||
|
|
||||||
|
const openQuickEdit = (row) => {
|
||||||
|
quickEditPaymentId.value = String(row._id || row.id);
|
||||||
|
quickEditVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
const createForm = reactive({
|
const createForm = reactive({
|
||||||
user: null,
|
user: null,
|
||||||
classes: [],
|
classes: [],
|
||||||
|
|||||||
@@ -0,0 +1,787 @@
|
|||||||
|
<!-- /src/views/waitlist/WaitlistListView.vue -->
|
||||||
|
<template>
|
||||||
|
<div class="waitlist-list-view">
|
||||||
|
<PageHeader title="لیست انتظار" subtitle="مدیریت دانشجویان در انتظار تشکیل کلاس و پیشثبتنامها">
|
||||||
|
<PermissionGate permission="waitlist:create">
|
||||||
|
<Button
|
||||||
|
label="افزودن به لیست انتظار"
|
||||||
|
icon="pi pi-user-plus"
|
||||||
|
severity="success"
|
||||||
|
@click="openCreateModal"
|
||||||
|
/>
|
||||||
|
</PermissionGate>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<!-- Stats Cards -->
|
||||||
|
<div class="grid mb-4" v-if="stats">
|
||||||
|
<div class="col-12 sm:col-6 lg:col-3">
|
||||||
|
<div class="surface-card p-3 border-round border-1 border-color shadow-sm flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-muted block mb-1">کل پیشثبتنامها</span>
|
||||||
|
<span class="text-2xl font-bold text-color">{{ toPersianDigits(stats.total || 0) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-3rem h-3rem border-round surface-100 flex align-items-center justify-content-center">
|
||||||
|
<i class="pi pi-users text-primary text-xl"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 lg:col-3">
|
||||||
|
<div class="surface-card p-3 border-round border-1 border-color shadow-sm flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-muted block mb-1">در انتظار کلاس</span>
|
||||||
|
<span class="text-2xl font-bold text-orange-500">{{ toPersianDigits(stats.waiting || 0) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-3rem h-3rem border-round surface-100 flex align-items-center justify-content-center">
|
||||||
|
<i class="pi pi-clock text-orange-500 text-xl"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 lg:col-3">
|
||||||
|
<div class="surface-card p-3 border-round border-1 border-color shadow-sm flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-muted block mb-1">انتقال یافته به کلاس</span>
|
||||||
|
<span class="text-2xl font-bold text-green-600">{{ toPersianDigits(stats.enrolled || 0) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-3rem h-3rem border-round surface-100 flex align-items-center justify-content-center">
|
||||||
|
<i class="pi pi-check-circle text-green-600 text-xl"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 lg:col-3">
|
||||||
|
<div class="surface-card p-3 border-round border-1 border-color shadow-sm flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-muted block mb-1">مسترد / لغوشده</span>
|
||||||
|
<span class="text-2xl font-bold text-muted">{{ toPersianDigits((stats.reverted || 0) + (stats.cancelled || 0)) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-3rem h-3rem border-round surface-100 flex align-items-center justify-content-center">
|
||||||
|
<i class="pi pi-replay text-muted text-xl"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter Bar -->
|
||||||
|
<div class="surface-card p-3 border-round border-1 border-color shadow-sm mb-4 flex flex-wrap gap-3 align-items-center justify-content-between">
|
||||||
|
<div class="flex flex-wrap gap-2 align-items-center flex-grow-1">
|
||||||
|
<Dropdown
|
||||||
|
v-model="selectedCourseFilter"
|
||||||
|
:options="coursesList"
|
||||||
|
optionLabel="title"
|
||||||
|
optionValue="_id"
|
||||||
|
placeholder="فیلتر بر اساس دوره…"
|
||||||
|
showClear
|
||||||
|
filter
|
||||||
|
class="text-sm"
|
||||||
|
style="min-width: 220px"
|
||||||
|
@change="onFilterChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dropdown
|
||||||
|
v-model="selectedStatusFilter"
|
||||||
|
:options="statusFilterOptions"
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
placeholder="فیلتر وضعیت…"
|
||||||
|
showClear
|
||||||
|
class="text-sm"
|
||||||
|
style="min-width: 170px"
|
||||||
|
@change="onFilterChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DataTable -->
|
||||||
|
<DataTableWrapper
|
||||||
|
:items="items"
|
||||||
|
:totalCount="totalCount"
|
||||||
|
:page="queryParams.page"
|
||||||
|
:limit="queryParams.limit"
|
||||||
|
:loading="isLoading"
|
||||||
|
@page-change="onPageChange"
|
||||||
|
@sort-change="onSort"
|
||||||
|
@search-change="onSearch"
|
||||||
|
>
|
||||||
|
<Column field="uniqueCode" header="کد رهگیری" style="width: 110px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="font-bold text-xs text-color">{{ data.uniqueCode || '—' }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="user" header="دانشجو / متقاضی">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="flex flex-column">
|
||||||
|
<span class="font-bold text-sm text-color">{{ data.user?.name || 'کاربر' }}</span>
|
||||||
|
<span v-if="data.user?.phoneNumber" class="text-xs text-muted" dir="ltr">{{ data.user.phoneNumber }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="course" header="دوره مورد تقاضا">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="font-semibold text-sm text-color">{{ data.course?.title || '—' }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="class" header="کلاس تخصیصیافته">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag v-if="data.class" :value="data.class.name" severity="success" class="text-xs" />
|
||||||
|
<Tag v-else value="هنوز کلاسی تعیین نشده" severity="secondary" class="text-xs opacity-75" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="payment" header="وضعیت مالی و واریزی">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div v-if="data.payment" class="flex flex-column gap-1">
|
||||||
|
<div class="flex align-items-center gap-2">
|
||||||
|
<span class="text-xs font-bold text-color">
|
||||||
|
{{ toPersianDigits(getPayableAmount(data.payment).toLocaleString()) }} تومان
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-pencil"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
v-tooltip.top="'ویرایش سریع صورتحساب'"
|
||||||
|
@click="openPaymentQuickEdit(data.payment)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex align-items-center gap-1">
|
||||||
|
<span class="text-xs text-green-600 font-semibold">
|
||||||
|
واریزی: {{ toPersianDigits((data.payment.paidAmount || 0).toLocaleString()) }} تومان
|
||||||
|
</span>
|
||||||
|
<StatusTag :status="data.payment.status" type="payment" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span v-else class="text-xs text-muted">—</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="status" header="وضعیت لیست انتظار">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag
|
||||||
|
v-if="data.status === 'waiting'"
|
||||||
|
value="در انتظار کلاس"
|
||||||
|
severity="warn"
|
||||||
|
class="text-xs font-semibold"
|
||||||
|
/>
|
||||||
|
<Tag
|
||||||
|
v-else-if="data.status === 'enrolled'"
|
||||||
|
value="انتقال به کلاس"
|
||||||
|
severity="success"
|
||||||
|
class="text-xs font-semibold"
|
||||||
|
/>
|
||||||
|
<Tag
|
||||||
|
v-else-if="data.status === 'reverted'"
|
||||||
|
value="مسترد شده"
|
||||||
|
severity="contrast"
|
||||||
|
class="text-xs font-semibold"
|
||||||
|
/>
|
||||||
|
<Tag
|
||||||
|
v-else-if="data.status === 'cancelled'"
|
||||||
|
value="لغوشده"
|
||||||
|
severity="secondary"
|
||||||
|
class="text-xs font-semibold"
|
||||||
|
/>
|
||||||
|
<Tag v-else :value="data.status" severity="info" class="text-xs" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="registeredAt" header="تاریخ ثبت">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="text-xs text-muted">{{ formatJalali(data.registeredAt || data.createdAt) }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column header="عملیات" style="width: 170px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="flex align-items-center gap-1">
|
||||||
|
<!-- Move to Class -->
|
||||||
|
<PermissionGate permission="waitlist:update">
|
||||||
|
<Button
|
||||||
|
v-if="data.status === 'waiting'"
|
||||||
|
icon="pi pi-arrow-left"
|
||||||
|
label="انتقال به کلاس"
|
||||||
|
size="small"
|
||||||
|
severity="success"
|
||||||
|
text
|
||||||
|
v-tooltip.top="'تخصیص به کلاس فعال'"
|
||||||
|
@click="openAssignModal(data)"
|
||||||
|
/>
|
||||||
|
</PermissionGate>
|
||||||
|
|
||||||
|
<!-- Revert / Refund -->
|
||||||
|
<PermissionGate permission="waitlist:update">
|
||||||
|
<Button
|
||||||
|
v-if="data.status === 'waiting'"
|
||||||
|
icon="pi pi-replay"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
severity="warn"
|
||||||
|
v-tooltip.top="'استرداد وجه'"
|
||||||
|
@click="openRevertModal(data)"
|
||||||
|
/>
|
||||||
|
</PermissionGate>
|
||||||
|
|
||||||
|
<!-- Cancel -->
|
||||||
|
<PermissionGate permission="waitlist:update">
|
||||||
|
<Button
|
||||||
|
v-if="data.status === 'waiting'"
|
||||||
|
icon="pi pi-ban"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
v-tooltip.top="'لغو پیشثبتنام'"
|
||||||
|
@click="openCancelModal(data)"
|
||||||
|
/>
|
||||||
|
</PermissionGate>
|
||||||
|
|
||||||
|
<!-- Delete -->
|
||||||
|
<PermissionGate permission="waitlist:delete">
|
||||||
|
<Button
|
||||||
|
icon="pi pi-trash"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
severity="danger"
|
||||||
|
v-tooltip.top="'حذف'"
|
||||||
|
@click="confirmDelete(data)"
|
||||||
|
/>
|
||||||
|
</PermissionGate>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTableWrapper>
|
||||||
|
|
||||||
|
<!-- Create Waitlist Modal -->
|
||||||
|
<Dialog v-model:visible="showCreateModal" header="افزودن دانشجو به لیست انتظار" modal :style="{ width: '560px', maxWidth: '95vw' }">
|
||||||
|
<div class="flex flex-column gap-3 py-2">
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">انتخاب دوره آموزشی *</label>
|
||||||
|
<Dropdown
|
||||||
|
v-model="createForm.courseId"
|
||||||
|
:options="coursesList"
|
||||||
|
optionLabel="title"
|
||||||
|
optionValue="_id"
|
||||||
|
placeholder="دوره را انتخاب کنید"
|
||||||
|
filter
|
||||||
|
class="w-full text-sm"
|
||||||
|
@change="onCourseSelected"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">انتخاب کاربر / دانشجو *</label>
|
||||||
|
<Dropdown
|
||||||
|
v-model="createForm.userId"
|
||||||
|
:options="usersList"
|
||||||
|
optionLabel="fullName"
|
||||||
|
optionValue="_id"
|
||||||
|
placeholder="دانشجو را انتخاب کنید"
|
||||||
|
filter
|
||||||
|
class="w-full text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="col-12 sm:col-6 flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">شهریه دوره (تومان) *</label>
|
||||||
|
<InputGroup>
|
||||||
|
<InputNumber v-model="createForm.amount" class="w-full text-sm" :min="0" />
|
||||||
|
<InputGroupAddon>تومان</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">تخفیف (تومان)</label>
|
||||||
|
<InputGroup>
|
||||||
|
<InputNumber v-model="createForm.discount" class="w-full text-sm" :min="0" :max="createForm.amount || 0" />
|
||||||
|
<InputGroupAddon>تومان</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex align-items-center gap-2 mt-1">
|
||||||
|
<Checkbox v-model="createForm.hasInitialPayment" binary inputId="wl-has-deposit" />
|
||||||
|
<label for="wl-has-deposit" class="font-semibold text-sm cursor-pointer">
|
||||||
|
پیشپرداخت / بیعانه هماکنون واریز شده است
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Initial Transaction fields -->
|
||||||
|
<div v-if="createForm.hasInitialPayment" class="p-3 border-1 border-color border-round flex flex-column gap-3 surface-50">
|
||||||
|
<h5 class="text-sm font-bold text-color m-0">مشخصات واریز بیعانه</h5>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="col-12 sm:col-6 flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-xs">مبلغ پرداختی (تومان) *</label>
|
||||||
|
<InputGroup>
|
||||||
|
<InputNumber v-model="createForm.depositAmount" class="w-full text-sm" :min="1" />
|
||||||
|
<InputGroupAddon>تومان</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-xs">روش پرداخت</label>
|
||||||
|
<Dropdown v-model="createForm.depositMethod" :options="depositMethodOptions" optionLabel="label" optionValue="value" class="w-full text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-xs">تاریخ پرداخت</label>
|
||||||
|
<DatePicker v-model="createForm.depositDate" class="w-full text-sm" :placeholder="getTodayJalali()" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-xs">شماره فیش / پیگیری</label>
|
||||||
|
<InputText v-model.trim="createForm.depositReceipt" class="w-full text-sm" dir="ltr" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">یادداشت داخلی</label>
|
||||||
|
<Textarea v-model="createForm.notes" rows="2" class="w-full text-sm" placeholder="توضیحات مربوط به متقاضی…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="انصراف" text severity="secondary" @click="showCreateModal = false" />
|
||||||
|
<Button label="ثبت در لیست انتظار" icon="pi pi-check" severity="success" :loading="isCreating" @click="handleCreateWaitlist" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Move / Assign to Class Modal -->
|
||||||
|
<Dialog v-model:visible="showAssignModal" header="انتقال دانشجو به کلاس" modal :style="{ width: '500px' }">
|
||||||
|
<div class="flex flex-column gap-3 py-2" v-if="selectedItem">
|
||||||
|
<div class="p-3 border-round surface-100 flex flex-column gap-1">
|
||||||
|
<span class="text-xs text-muted">دانشجو: <strong class="text-color">{{ selectedItem.user?.name }}</strong></span>
|
||||||
|
<span class="text-xs text-muted">دوره: <strong class="text-color">{{ selectedItem.course?.title }}</strong></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">انتخاب کلاس مورد نظر برای انتقال *</label>
|
||||||
|
<Dropdown
|
||||||
|
v-model="assignClassId"
|
||||||
|
:options="courseClasses"
|
||||||
|
optionLabel="displayName"
|
||||||
|
optionValue="_id"
|
||||||
|
placeholder="کلاس را انتخاب کنید"
|
||||||
|
class="w-full text-sm"
|
||||||
|
filter
|
||||||
|
/>
|
||||||
|
<small v-if="!courseClasses.length" class="text-orange-500 text-xs">
|
||||||
|
هیچ کلاس فعالی برای این دوره یافت نشد. ابتدا باید یک کلاس ایجاد کنید.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-2 border-round surface-50 border-1 border-color text-xs text-muted line-height-3">
|
||||||
|
<i class="pi pi-info-circle text-primary ml-1"></i>
|
||||||
|
با انتقال دانشجو به کلاس، وضعیت صورتحساب از «لیست انتظار» به حالت عادی تغییر کرده و پرداختیها به عنوان درآمد واقعی کلاس و در سهم استاد محاسبه خواهند شد.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="انصراف" text severity="secondary" @click="showAssignModal = false" />
|
||||||
|
<Button
|
||||||
|
label="تأیید و انتقال به کلاس"
|
||||||
|
icon="pi pi-check"
|
||||||
|
severity="success"
|
||||||
|
:loading="isAssigning"
|
||||||
|
:disabled="!assignClassId"
|
||||||
|
@click="submitAssignClass"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Revert / Refund Confirmation Dialog -->
|
||||||
|
<Dialog v-model:visible="showRevertModal" header="استرداد وجه و لغو پیشثبتنام" modal :style="{ width: '450px' }">
|
||||||
|
<div class="flex flex-column gap-3 py-2" v-if="selectedItem">
|
||||||
|
<div class="flex align-items-center gap-2 text-yellow-600">
|
||||||
|
<i class="pi pi-exclamation-triangle text-2xl"></i>
|
||||||
|
<span class="font-bold text-sm">آیا از استرداد وجه متقاضی «{{ selectedItem.user?.name }}» اطمینان دارید؟</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted m-0 line-height-3">
|
||||||
|
با استرداد، وضعیت این پیشثبتنام و تمام صورتحسابها و تراکنشهای مربوطه به وضعیت «مسترد شده» تغییر خواهند یافت.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-xs">دلیل یا توضیحات استرداد</label>
|
||||||
|
<Textarea v-model="revertNotes" rows="2" class="w-full text-sm" placeholder="مثلا: عدم تشکیل کلاس در موعد مقرر…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="انصراف" text severity="secondary" @click="showRevertModal = false" />
|
||||||
|
<Button label="استرداد وجه و ثبت" icon="pi pi-replay" severity="warn" :loading="isReverting" @click="submitRevert" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Cancel Confirmation Dialog -->
|
||||||
|
<Dialog v-model:visible="showCancelModal" header="لغو پیشثبتنام" modal :style="{ width: '450px' }">
|
||||||
|
<div class="flex flex-column gap-3 py-2" v-if="selectedItem">
|
||||||
|
<div class="flex align-items-center gap-2 text-red-500">
|
||||||
|
<i class="pi pi-ban text-2xl"></i>
|
||||||
|
<span class="font-bold text-sm">آیا از لغو پیشثبتنام «{{ selectedItem.user?.name }}» اطمینان دارید؟</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-xs">دلیل لغو</label>
|
||||||
|
<Textarea v-model="cancelNotes" rows="2" class="w-full text-sm" placeholder="توضیحات لغو…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="انصراف" text severity="secondary" @click="showCancelModal = false" />
|
||||||
|
<Button label="لغو پیشثبتنام" icon="pi pi-check" severity="danger" :loading="isCancelling" @click="submitCancel" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation -->
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
v-model="deleteDialogVisible"
|
||||||
|
:loading="isDeleting"
|
||||||
|
@confirm="handleDelete"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Quick Edit Payment Modal -->
|
||||||
|
<QuickEditPaymentDialog
|
||||||
|
v-model:visible="quickEditVisible"
|
||||||
|
:payment-id="quickEditPaymentId"
|
||||||
|
@updated="refreshAll"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted } from 'vue';
|
||||||
|
import { waitlistApi } from '@/api/waitlistApi';
|
||||||
|
import { courseApi } from '@/api/courseApi';
|
||||||
|
import { userApi } from '@/api/userApi';
|
||||||
|
import { classApi } from '@/api/classApi';
|
||||||
|
import { useDataTable } from '@/composables/useDataTable';
|
||||||
|
import { usePersianDate } from '@/composables/usePersianDate';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
import { getPayableAmount } from '@/utils/paymentAmount';
|
||||||
|
import PageHeader from '@/components/common/PageHeader.vue';
|
||||||
|
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
|
||||||
|
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
|
||||||
|
import PermissionGate from '@/components/common/PermissionGate.vue';
|
||||||
|
import StatusTag from '@/components/common/StatusTag.vue';
|
||||||
|
import QuickEditPaymentDialog from '@/components/payments/QuickEditPaymentDialog.vue';
|
||||||
|
import Button from 'primevue/button';
|
||||||
|
import Column from 'primevue/column';
|
||||||
|
import Tag from 'primevue/tag';
|
||||||
|
import Dialog from 'primevue/dialog';
|
||||||
|
import Dropdown from 'primevue/select';
|
||||||
|
import InputText from 'primevue/inputtext';
|
||||||
|
import InputNumber from 'primevue/inputnumber';
|
||||||
|
import InputGroup from 'primevue/inputgroup';
|
||||||
|
import InputGroupAddon from 'primevue/inputgroupaddon';
|
||||||
|
import Checkbox from 'primevue/checkbox';
|
||||||
|
import Textarea from 'primevue/textarea';
|
||||||
|
import DatePicker from 'vue3-persian-datetime-picker';
|
||||||
|
|
||||||
|
const { toPersianDigits, formatJalali, toGregorianIso, getTodayJalali } = usePersianDate();
|
||||||
|
const { showSuccess, showError } = useToast();
|
||||||
|
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
totalCount,
|
||||||
|
isLoading,
|
||||||
|
queryParams,
|
||||||
|
loadData,
|
||||||
|
onPageChange,
|
||||||
|
onSort,
|
||||||
|
onSearch
|
||||||
|
} = useDataTable(waitlistApi.getAll);
|
||||||
|
|
||||||
|
const stats = ref(null);
|
||||||
|
const coursesList = ref([]);
|
||||||
|
const usersList = ref([]);
|
||||||
|
const selectedCourseFilter = ref(null);
|
||||||
|
const selectedStatusFilter = ref(null);
|
||||||
|
|
||||||
|
const statusFilterOptions = [
|
||||||
|
{ label: 'همه وضعیتها', value: null },
|
||||||
|
{ label: 'در انتظار کلاس', value: 'waiting' },
|
||||||
|
{ label: 'انتقال به کلاس', value: 'enrolled' },
|
||||||
|
{ label: 'مسترد شده', value: 'reverted' },
|
||||||
|
{ label: 'لغوشده', value: 'cancelled' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const depositMethodOptions = [
|
||||||
|
{ label: 'کارت به کارت', value: 'card' },
|
||||||
|
{ label: 'درگاه آنلاین', value: 'online' },
|
||||||
|
{ label: 'نقدی', value: 'cash' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Quick Edit Modal
|
||||||
|
const quickEditVisible = ref(false);
|
||||||
|
const quickEditPaymentId = ref('');
|
||||||
|
const openPaymentQuickEdit = (pmt) => {
|
||||||
|
const id = typeof pmt === 'object' ? (pmt._id || pmt.id) : pmt;
|
||||||
|
quickEditPaymentId.value = String(id);
|
||||||
|
quickEditVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create Modal
|
||||||
|
const showCreateModal = ref(false);
|
||||||
|
const isCreating = ref(false);
|
||||||
|
const createForm = reactive({
|
||||||
|
courseId: null,
|
||||||
|
userId: null,
|
||||||
|
amount: 0,
|
||||||
|
discount: 0,
|
||||||
|
hasInitialPayment: true,
|
||||||
|
depositAmount: 0,
|
||||||
|
depositMethod: 'card',
|
||||||
|
depositDate: getTodayJalali(),
|
||||||
|
depositReceipt: '',
|
||||||
|
notes: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assign to class modal
|
||||||
|
const showAssignModal = ref(false);
|
||||||
|
const isAssigning = ref(false);
|
||||||
|
const selectedItem = ref(null);
|
||||||
|
const assignClassId = ref(null);
|
||||||
|
const courseClasses = ref([]);
|
||||||
|
|
||||||
|
// Revert Modal
|
||||||
|
const showRevertModal = ref(false);
|
||||||
|
const isReverting = ref(false);
|
||||||
|
const revertNotes = ref('');
|
||||||
|
|
||||||
|
// Cancel Modal
|
||||||
|
const showCancelModal = ref(false);
|
||||||
|
const isCancelling = ref(false);
|
||||||
|
const cancelNotes = ref('');
|
||||||
|
|
||||||
|
// Delete Modal
|
||||||
|
const deleteDialogVisible = ref(false);
|
||||||
|
const isDeleting = ref(false);
|
||||||
|
const itemToDelete = ref(null);
|
||||||
|
|
||||||
|
const fetchStats = async () => {
|
||||||
|
try {
|
||||||
|
const res = await waitlistApi.getStats();
|
||||||
|
stats.value = res.data || res;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to fetch waitlist stats:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchDropdowns = async () => {
|
||||||
|
try {
|
||||||
|
const [cRes, uRes] = await Promise.allSettled([
|
||||||
|
courseApi.getAll({ limit: 100 }),
|
||||||
|
userApi.getAll({ limit: 200 })
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (cRes.status === 'fulfilled') {
|
||||||
|
const d = cRes.value.data || cRes.value;
|
||||||
|
coursesList.value = Array.isArray(d) ? d : (d.items || d.courses || d.data || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uRes.status === 'fulfilled') {
|
||||||
|
const d = uRes.value.data || uRes.value;
|
||||||
|
const list = Array.isArray(d) ? d : (d.items || d.users || d.data || []);
|
||||||
|
usersList.value = list.map((u) => ({
|
||||||
|
...u,
|
||||||
|
fullName: `${u.name || ''} — ${u.phoneNumber || ''}`.trim()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Dropdown prefetch error:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFilterChange = () => {
|
||||||
|
queryParams.courseId = selectedCourseFilter.value || undefined;
|
||||||
|
queryParams.status = selectedStatusFilter.value || undefined;
|
||||||
|
queryParams.page = 1;
|
||||||
|
loadData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreateModal = () => {
|
||||||
|
Object.assign(createForm, {
|
||||||
|
courseId: null,
|
||||||
|
userId: null,
|
||||||
|
amount: 0,
|
||||||
|
discount: 0,
|
||||||
|
hasInitialPayment: true,
|
||||||
|
depositAmount: 0,
|
||||||
|
depositMethod: 'card',
|
||||||
|
depositDate: getTodayJalali(),
|
||||||
|
depositReceipt: '',
|
||||||
|
notes: ''
|
||||||
|
});
|
||||||
|
showCreateModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCourseSelected = () => {
|
||||||
|
const selected = coursesList.value.find((c) => String(c._id) === String(createForm.courseId));
|
||||||
|
if (selected) {
|
||||||
|
createForm.amount = selected.price || 0;
|
||||||
|
createForm.depositAmount = selected.price || 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateWaitlist = async () => {
|
||||||
|
if (!createForm.courseId) {
|
||||||
|
showError('انتخاب دوره الزامی است');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!createForm.userId) {
|
||||||
|
showError('انتخاب دانشجو الزامی است');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isCreating.value = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
courseId: createForm.courseId,
|
||||||
|
userId: createForm.userId,
|
||||||
|
amount: createForm.amount,
|
||||||
|
discount: createForm.discount,
|
||||||
|
notes: createForm.notes
|
||||||
|
};
|
||||||
|
|
||||||
|
if (createForm.hasInitialPayment && createForm.depositAmount > 0) {
|
||||||
|
payload.initialTransaction = {
|
||||||
|
amount: createForm.depositAmount,
|
||||||
|
method: createForm.depositMethod,
|
||||||
|
date: toGregorianIso(createForm.depositDate),
|
||||||
|
receiptNumber: createForm.depositReceipt,
|
||||||
|
notes: createForm.notes || 'پرداخت بیعانه ثبتنام لیست انتظار',
|
||||||
|
status: 'paid'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await waitlistApi.create(payload);
|
||||||
|
showSuccess('دانشجو با موفقیت در لیست انتظار ثبت شد');
|
||||||
|
showCreateModal.value = false;
|
||||||
|
refreshAll();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isCreating.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAssignModal = async (item) => {
|
||||||
|
selectedItem.value = item;
|
||||||
|
assignClassId.value = null;
|
||||||
|
courseClasses.value = [];
|
||||||
|
showAssignModal.value = true;
|
||||||
|
|
||||||
|
const courseId = item.course?._id || item.course;
|
||||||
|
if (courseId) {
|
||||||
|
try {
|
||||||
|
const res = await classApi.getAll({ courseId, limit: 50 });
|
||||||
|
const data = res.data || res;
|
||||||
|
const list = Array.isArray(data) ? data : (data.items || data.classes || data.data || []);
|
||||||
|
courseClasses.value = list.map((c) => ({
|
||||||
|
...c,
|
||||||
|
displayName: `${c.name} (ظرفیت: ${c.capacity || 0} نفر — شهریه: ${toPersianDigits((c.tuitionFee || 0).toLocaleString())} تومان)`
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to fetch course classes:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitAssignClass = async () => {
|
||||||
|
if (!selectedItem.value?._id || !assignClassId.value) return;
|
||||||
|
isAssigning.value = true;
|
||||||
|
try {
|
||||||
|
await waitlistApi.assignClass(selectedItem.value._id, { classId: assignClassId.value });
|
||||||
|
showSuccess('دانشجو با موفقیت به کلاس منتقل شد و صورتحساب به کلاس متصل گردید');
|
||||||
|
showAssignModal.value = false;
|
||||||
|
selectedItem.value = null;
|
||||||
|
refreshAll();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isAssigning.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRevertModal = (item) => {
|
||||||
|
selectedItem.value = item;
|
||||||
|
revertNotes.value = '';
|
||||||
|
showRevertModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitRevert = async () => {
|
||||||
|
if (!selectedItem.value?._id) return;
|
||||||
|
isReverting.value = true;
|
||||||
|
try {
|
||||||
|
await waitlistApi.revert(selectedItem.value._id, { notes: revertNotes.value });
|
||||||
|
showSuccess('پیشثبتنام با موفقیت مسترد شد و وضعیت تراکنشها بهروز گردید');
|
||||||
|
showRevertModal.value = false;
|
||||||
|
selectedItem.value = null;
|
||||||
|
refreshAll();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isReverting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCancelModal = (item) => {
|
||||||
|
selectedItem.value = item;
|
||||||
|
cancelNotes.value = '';
|
||||||
|
showCancelModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitCancel = async () => {
|
||||||
|
if (!selectedItem.value?._id) return;
|
||||||
|
isCancelling.value = true;
|
||||||
|
try {
|
||||||
|
await waitlistApi.cancel(selectedItem.value._id, { notes: cancelNotes.value });
|
||||||
|
showSuccess('پیشثبتنام با موفقیت لغو شد');
|
||||||
|
showCancelModal.value = false;
|
||||||
|
selectedItem.value = null;
|
||||||
|
refreshAll();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isCancelling.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = (item) => {
|
||||||
|
itemToDelete.value = item;
|
||||||
|
deleteDialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!itemToDelete.value?._id) return;
|
||||||
|
isDeleting.value = true;
|
||||||
|
try {
|
||||||
|
await waitlistApi.delete(itemToDelete.value._id);
|
||||||
|
showSuccess('مورد از لیست انتظار حذف شد');
|
||||||
|
deleteDialogVisible.value = false;
|
||||||
|
itemToDelete.value = null;
|
||||||
|
refreshAll();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isDeleting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshAll = () => {
|
||||||
|
loadData();
|
||||||
|
fetchStats();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchDropdowns();
|
||||||
|
fetchStats();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.waitlist-list-view {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user