feat: add waitlist view, quick edit payment dialog, and catering fee in class views

This commit is contained in:
2026-08-23 23:00:23 +03:30
parent 39921b548e
commit 6d64c2931d
13 changed files with 1598 additions and 7 deletions
+3 -1
View File
@@ -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}`)
}; };
+14
View File
@@ -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}`)
};
+3 -1
View File
@@ -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') {
+1
View File
@@ -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>
+17 -1
View File
@@ -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: 'جلسات آموزشی',
+8
View File
@@ -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',
+4 -2
View File
@@ -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 } = {}) {
+13
View File
@@ -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',
+4
View File
@@ -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" />
+15 -1
View File
@@ -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)
}; };
+28 -1
View File
@@ -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: [],
+787
View File
@@ -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>