feat(payments): add bulk payment modal and duplicate payment warning in dialogs
This commit is contained in:
@@ -6,6 +6,8 @@ export const paymentApi = {
|
|||||||
search: (params) => axiosInstance.get('/payments/admin/search', { params }),
|
search: (params) => axiosInstance.get('/payments/admin/search', { params }),
|
||||||
getOne: (id) => axiosInstance.get(`/payments/admin/get-one/${id}`),
|
getOne: (id) => axiosInstance.get(`/payments/admin/get-one/${id}`),
|
||||||
create: (data) => axiosInstance.post('/payments/admin/create', data),
|
create: (data) => axiosInstance.post('/payments/admin/create', data),
|
||||||
|
createBulkClass: (data) => axiosInstance.post('/payments/admin/bulk-class', data),
|
||||||
|
checkDuplicate: (params) => axiosInstance.get('/payments/admin/check-duplicate', { params }),
|
||||||
update: (id, data) => axiosInstance.put(`/payments/admin/update/${id}`, data),
|
update: (id, data) => axiosInstance.put(`/payments/admin/update/${id}`, data),
|
||||||
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),
|
||||||
|
|||||||
@@ -11,6 +11,15 @@
|
|||||||
@click="$router.push(`/financial-reports?classId=${classId}`)"
|
@click="$router.push(`/financial-reports?classId=${classId}`)"
|
||||||
/>
|
/>
|
||||||
</PermissionGate>
|
</PermissionGate>
|
||||||
|
<PermissionGate permission="payments:create">
|
||||||
|
<Button
|
||||||
|
label="صدور صورتحساب گروهی"
|
||||||
|
icon="pi pi-wallet"
|
||||||
|
severity="success"
|
||||||
|
class="ml-2"
|
||||||
|
@click="openBulkModal"
|
||||||
|
/>
|
||||||
|
</PermissionGate>
|
||||||
<Button
|
<Button
|
||||||
label="حذف کلاس"
|
label="حذف کلاس"
|
||||||
icon="pi pi-trash"
|
icon="pi pi-trash"
|
||||||
@@ -151,31 +160,111 @@
|
|||||||
:loading="isDeletingClass"
|
:loading="isDeletingClass"
|
||||||
@confirm="handleDeleteClass"
|
@confirm="handleDeleteClass"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Bulk Class Payment Modal -->
|
||||||
|
<Dialog v-model:visible="bulkModalVisible" header="صدور صورتحساب گروهی برای این کلاس" modal :style="{ width: '540px' }">
|
||||||
|
<div class="flex flex-column gap-3 py-2" v-if="classData">
|
||||||
|
<div class="p-3 bg-blue-50 border-round border-1 border-blue-200 text-blue-900 text-sm flex align-items-center justify-content-between">
|
||||||
|
<span>تعداد دانشجویان ثبتنامشده:</span>
|
||||||
|
<span class="font-bold">{{ toPersianDigits((classData.students || []).length) }} نفر</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm" for="class-bulk-amount">مبلغ شهریه هر دانشجو (تومان) *</label>
|
||||||
|
<InputNumber inputId="class-bulk-amount" v-model="bulkForm.amount" class="w-full text-sm" suffix=" تومان" :min="0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex align-items-center gap-2">
|
||||||
|
<Checkbox v-model="hasBulkDiscount" binary inputId="class-bulk-discount" />
|
||||||
|
<label for="class-bulk-discount" class="font-semibold text-sm cursor-pointer">تخفیف همگانی ؟</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="hasBulkDiscount" class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm" for="class-bulk-discount-amount">مبلغ تخفیف (تومان)</label>
|
||||||
|
<InputNumber
|
||||||
|
inputId="class-bulk-discount-amount"
|
||||||
|
v-model="bulkForm.discount"
|
||||||
|
class="w-full text-sm"
|
||||||
|
suffix=" تومان"
|
||||||
|
:min="0"
|
||||||
|
:max="bulkForm.amount || 0"
|
||||||
|
/>
|
||||||
|
<small class="font-semibold text-color">
|
||||||
|
مبلغ نهایی هر صورتحساب: {{ toPersianDigits(bulkPayableAmount.toLocaleString()) }} تومان
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">تاریخ سررسید *</label>
|
||||||
|
<DatePicker v-model="bulkForm.dueDate" class="w-full text-sm" :placeholder="getTodayJalali()" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex align-items-center gap-2">
|
||||||
|
<Checkbox v-model="bulkForm.skipExisting" binary inputId="class-bulk-skip-existing" />
|
||||||
|
<label for="class-bulk-skip-existing" class="text-sm cursor-pointer font-medium">
|
||||||
|
عدم صدور مجدد برای دانشجویانی که قبلاً در این کلاس صورتحساب دارند
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm" for="class-bulk-notes">یادداشت</label>
|
||||||
|
<Textarea
|
||||||
|
id="class-bulk-notes"
|
||||||
|
v-model="bulkForm.notes"
|
||||||
|
rows="2"
|
||||||
|
class="w-full text-sm"
|
||||||
|
maxlength="5000"
|
||||||
|
placeholder="یادداشت برای تمام صورتحسابهای صادره…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NotifyChannelsField :notify="bulkNotify" />
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="انصراف" text severity="secondary" @click="bulkModalVisible = false" />
|
||||||
|
<Button
|
||||||
|
label="صدور صورتحسابها"
|
||||||
|
icon="pi pi-check"
|
||||||
|
severity="success"
|
||||||
|
:loading="isBulkCreating"
|
||||||
|
:disabled="!(classData?.students?.length)"
|
||||||
|
@click="handleBulkCreatePayment"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue';
|
import { ref, reactive, computed, onMounted } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { classApi } from '@/api/classApi';
|
import { classApi } from '@/api/classApi';
|
||||||
import { formatClassDays, formatClassTime } from '@/utils/classSchedule';
|
import { paymentApi } from '@/api/paymentApi';
|
||||||
|
import { formatClassDays, formatClassTime, calculateClassMidDate } from '@/utils/classSchedule';
|
||||||
import { sessionApi } from '@/api/sessionApi';
|
import { sessionApi } from '@/api/sessionApi';
|
||||||
import { usePersianDate } from '@/composables/usePersianDate';
|
import { usePersianDate } from '@/composables/usePersianDate';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { usePermission } from '@/composables/usePermission';
|
import { usePermission } from '@/composables/usePermission';
|
||||||
|
import { getPayableAmount } from '@/utils/paymentAmount';
|
||||||
import PageHeader from '@/components/common/PageHeader.vue';
|
import PageHeader from '@/components/common/PageHeader.vue';
|
||||||
import StatusTag from '@/components/common/StatusTag.vue';
|
import StatusTag from '@/components/common/StatusTag.vue';
|
||||||
import TableSkeleton from '@/components/common/TableSkeleton.vue';
|
import TableSkeleton from '@/components/common/TableSkeleton.vue';
|
||||||
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
|
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
|
||||||
import PermissionGate from '@/components/common/PermissionGate.vue';
|
import PermissionGate from '@/components/common/PermissionGate.vue';
|
||||||
|
import NotifyChannelsField from '@/components/common/NotifyChannelsField.vue';
|
||||||
import Button from 'primevue/button';
|
import Button from 'primevue/button';
|
||||||
import DataTable from 'primevue/datatable';
|
import DataTable from 'primevue/datatable';
|
||||||
import Column from 'primevue/column';
|
import Column from 'primevue/column';
|
||||||
|
import Dialog from 'primevue/dialog';
|
||||||
|
import InputNumber from 'primevue/inputnumber';
|
||||||
|
import Checkbox from 'primevue/checkbox';
|
||||||
|
import Textarea from 'primevue/textarea';
|
||||||
|
import DatePicker from 'vue3-persian-datetime-picker';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const classId = route.params.id;
|
const classId = route.params.id;
|
||||||
const { toPersianDigits, formatJalali } = usePersianDate();
|
const { toPersianDigits, formatJalali, toGregorianIso, getTodayJalali } = usePersianDate();
|
||||||
const { showError, showSuccess } = useToast();
|
const { showError, showSuccess } = useToast();
|
||||||
const { hasPermission } = usePermission();
|
const { hasPermission } = usePermission();
|
||||||
const canManageStudents = computed(() => hasPermission('classes:register_users'));
|
const canManageStudents = computed(() => hasPermission('classes:register_users'));
|
||||||
@@ -190,6 +279,86 @@ const studentToRemove = ref(null);
|
|||||||
const deleteClassDialogVisible = ref(false);
|
const deleteClassDialogVisible = ref(false);
|
||||||
const isDeletingClass = ref(false);
|
const isDeletingClass = ref(false);
|
||||||
|
|
||||||
|
// Bulk Payment State
|
||||||
|
const bulkModalVisible = ref(false);
|
||||||
|
const isBulkCreating = ref(false);
|
||||||
|
const hasBulkDiscount = ref(false);
|
||||||
|
const bulkNotify = reactive({ sms: true, email: true, bot: true });
|
||||||
|
const bulkForm = reactive({
|
||||||
|
amount: 0,
|
||||||
|
discount: 0,
|
||||||
|
notes: '',
|
||||||
|
dueDate: getTodayJalali(),
|
||||||
|
skipExisting: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const bulkPayableAmount = computed(() => getPayableAmount({
|
||||||
|
amount: bulkForm.amount,
|
||||||
|
discount: hasBulkDiscount.value ? bulkForm.discount : 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
const openBulkModal = () => {
|
||||||
|
if (!classData.value) return;
|
||||||
|
bulkForm.amount = classData.value.tuitionFee || classData.value.course?.price || 0;
|
||||||
|
if (classData.value.hasDiscount && classData.value.discount) {
|
||||||
|
hasBulkDiscount.value = true;
|
||||||
|
bulkForm.discount = classData.value.discount;
|
||||||
|
} else {
|
||||||
|
hasBulkDiscount.value = false;
|
||||||
|
bulkForm.discount = 0;
|
||||||
|
}
|
||||||
|
bulkForm.notes = '';
|
||||||
|
bulkForm.skipExisting = true;
|
||||||
|
bulkNotify.sms = true;
|
||||||
|
bulkNotify.email = true;
|
||||||
|
bulkNotify.bot = true;
|
||||||
|
bulkForm.dueDate = calculateClassMidDate(classData.value, sessions.value);
|
||||||
|
bulkModalVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkCreatePayment = async () => {
|
||||||
|
if (!classData.value?.students?.length) {
|
||||||
|
showError('هیچ دانشجویی در این کلاس ثبتنام نشده است');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!bulkForm.amount) { showError('لطفا مبلغ شهریه را وارد کنید'); return; }
|
||||||
|
if (!bulkForm.dueDate) { showError('لطفا تاریخ سررسید را وارد کنید'); return; }
|
||||||
|
const dueDate = toGregorianIso(bulkForm.dueDate);
|
||||||
|
if (!dueDate) { showError('تاریخ سررسید نامعتبر است'); return; }
|
||||||
|
|
||||||
|
if (hasBulkDiscount.value && (bulkForm.discount || 0) > bulkForm.amount) {
|
||||||
|
showError('مبلغ تخفیف نمیتواند بیشتر از مبلغ کل باشد');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isBulkCreating.value = true;
|
||||||
|
try {
|
||||||
|
const res = await paymentApi.createBulkClass({
|
||||||
|
classId,
|
||||||
|
amount: bulkForm.amount,
|
||||||
|
discount: hasBulkDiscount.value ? (bulkForm.discount || 0) : 0,
|
||||||
|
dueDate,
|
||||||
|
notes: bulkForm.notes,
|
||||||
|
skipExisting: bulkForm.skipExisting,
|
||||||
|
notify: { ...bulkNotify }
|
||||||
|
});
|
||||||
|
const result = res.data?.data || res.data || res;
|
||||||
|
const createdCount = result.createdCount ?? 0;
|
||||||
|
const skippedCount = result.skippedCount ?? 0;
|
||||||
|
|
||||||
|
let msg = `صورتحساب برای ${toPersianDigits(createdCount)} دانشجو با موفقیت ایجاد شد`;
|
||||||
|
if (skippedCount > 0) {
|
||||||
|
msg += ` (${toPersianDigits(skippedCount)} دانشجو به دلیل داشتن صورتحساب قبلی رد شدند)`;
|
||||||
|
}
|
||||||
|
showSuccess(msg);
|
||||||
|
bulkModalVisible.value = false;
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isBulkCreating.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const deleteClassDialogMessage = computed(() => {
|
const deleteClassDialogMessage = computed(() => {
|
||||||
const name = classData.value?.name;
|
const name = classData.value?.name;
|
||||||
return name
|
return name
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<div class="payment-list-view">
|
<div class="payment-list-view">
|
||||||
<PageHeader :title="$t('payments.title')" :subtitle="$t('payments.subtitle')">
|
<PageHeader :title="$t('payments.title')" :subtitle="$t('payments.subtitle')">
|
||||||
<PermissionGate permission="payments:create">
|
<PermissionGate permission="payments:create">
|
||||||
|
<Button label="صدور صورتحساب گروهی" icon="pi pi-users" severity="info" class="ml-2" @click="openBulkModal" />
|
||||||
<Button :label="$t('payments.addPayment')" icon="pi pi-plus" severity="success" @click="openCreateModal" />
|
<Button :label="$t('payments.addPayment')" icon="pi pi-plus" severity="success" @click="openCreateModal" />
|
||||||
</PermissionGate>
|
</PermissionGate>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
@@ -108,12 +109,22 @@
|
|||||||
:placeholder="createForm.classes?.length ? 'دانشجوی ثبتنامشده را انتخاب کنید' : 'ابتدا کلاس را انتخاب کنید'"
|
:placeholder="createForm.classes?.length ? 'دانشجوی ثبتنامشده را انتخاب کنید' : 'ابتدا کلاس را انتخاب کنید'"
|
||||||
class="w-full text-sm"
|
class="w-full text-sm"
|
||||||
:disabled="!createForm.classes?.length"
|
:disabled="!createForm.classes?.length"
|
||||||
|
@change="checkDuplicateInvoice"
|
||||||
/>
|
/>
|
||||||
<small v-if="createForm.classes?.length && !eligibleUsers.length" class="text-orange-500">
|
<small v-if="createForm.classes?.length && !eligibleUsers.length" class="text-orange-500">
|
||||||
هیچ دانشجوی ثبتنامشدهای در کلاسهای انتخابشده یافت نشد
|
هیچ دانشجوی ثبتنامشدهای در کلاسهای انتخابشده یافت نشد
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Duplicate Payment Warning -->
|
||||||
|
<div v-if="duplicateWarning?.hasDuplicate" class="p-3 bg-yellow-50 border-round border-1 border-yellow-400 text-yellow-900 text-sm flex align-items-start gap-2">
|
||||||
|
<i class="pi pi-exclamation-triangle text-yellow-600 text-lg mt-1 flex-shrink-0"></i>
|
||||||
|
<div class="flex flex-column gap-1">
|
||||||
|
<span class="font-bold">هشدار: صورتحساب تکراری</span>
|
||||||
|
<span>برای این دانشجو قبلاً در کلاسهای انتخابشده صورتحساب ثبت شده است.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-column gap-2">
|
<div class="flex flex-column gap-2">
|
||||||
<label class="font-semibold text-sm" for="invoice-amount">مبلغ کل صورتحساب (تومان) *</label>
|
<label class="font-semibold text-sm" for="invoice-amount">مبلغ کل صورتحساب (تومان) *</label>
|
||||||
<InputNumber inputId="invoice-amount" v-model="createForm.amount" class="w-full text-sm" suffix=" تومان" :min="0" />
|
<InputNumber inputId="invoice-amount" v-model="createForm.amount" class="w-full text-sm" suffix=" تومان" :min="0" />
|
||||||
@@ -164,6 +175,92 @@
|
|||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Bulk Class Payment Modal -->
|
||||||
|
<Dialog v-model:visible="showBulkModal" header="صدور صورتحساب گروهی برای کلاس" modal :style="{ width: '560px' }">
|
||||||
|
<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="bulkForm.classId"
|
||||||
|
:options="classesList"
|
||||||
|
optionLabel="name"
|
||||||
|
optionValue="_id"
|
||||||
|
filter
|
||||||
|
placeholder="کلاس را انتخاب کنید"
|
||||||
|
class="w-full text-sm"
|
||||||
|
@change="onBulkClassSelected"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedBulkClass" class="p-3 bg-blue-50 border-round border-1 border-blue-200 text-blue-900 text-sm flex align-items-center justify-content-between">
|
||||||
|
<span>تعداد کل دانشجویان کلاس:</span>
|
||||||
|
<span class="font-bold">{{ toPersianDigits((selectedBulkClass.students || []).length) }} نفر</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm" for="bulk-invoice-amount">مبلغ شهریه هر دانشجو (تومان) *</label>
|
||||||
|
<InputNumber inputId="bulk-invoice-amount" v-model="bulkForm.amount" class="w-full text-sm" suffix=" تومان" :min="0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex align-items-center gap-2">
|
||||||
|
<Checkbox v-model="hasBulkDiscount" binary inputId="bulk-invoice-discount" />
|
||||||
|
<label for="bulk-invoice-discount" class="font-semibold text-sm cursor-pointer">تخفیف همگانی ؟</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="hasBulkDiscount" class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm" for="bulk-invoice-discount-amount">مبلغ تخفیف (تومان)</label>
|
||||||
|
<InputNumber
|
||||||
|
inputId="bulk-invoice-discount-amount"
|
||||||
|
v-model="bulkForm.discount"
|
||||||
|
class="w-full text-sm"
|
||||||
|
suffix=" تومان"
|
||||||
|
:min="0"
|
||||||
|
:max="bulkForm.amount || 0"
|
||||||
|
/>
|
||||||
|
<small class="font-semibold text-color">
|
||||||
|
مبلغ نهایی هر صورتحساب: {{ toPersianDigits(bulkPayableAmount.toLocaleString()) }} تومان
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">تاریخ سررسید *</label>
|
||||||
|
<DatePicker v-model="bulkForm.dueDate" class="w-full text-sm" :placeholder="getTodayJalali()" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex align-items-center gap-2">
|
||||||
|
<Checkbox v-model="bulkForm.skipExisting" binary inputId="bulk-skip-existing" />
|
||||||
|
<label for="bulk-skip-existing" class="text-sm cursor-pointer font-medium">
|
||||||
|
عدم صدور مجدد برای دانشجویانی که قبلاً در این کلاس صورتحساب دارند
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm" for="bulk-invoice-notes">یادداشت</label>
|
||||||
|
<Textarea
|
||||||
|
id="bulk-invoice-notes"
|
||||||
|
v-model="bulkForm.notes"
|
||||||
|
rows="2"
|
||||||
|
class="w-full text-sm"
|
||||||
|
maxlength="5000"
|
||||||
|
placeholder="یادداشت برای تمام صورتحسابهای صادره…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NotifyChannelsField :notify="bulkNotify" />
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="انصراف" text severity="secondary" @click="showBulkModal = false" />
|
||||||
|
<Button
|
||||||
|
label="صدور صورتحسابها"
|
||||||
|
icon="pi pi-check"
|
||||||
|
severity="success"
|
||||||
|
:loading="isBulkCreating"
|
||||||
|
:disabled="!bulkForm.classId || !(selectedBulkClass?.students?.length)"
|
||||||
|
@click="handleBulkCreatePayment"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<ConfirmDeleteDialog
|
<ConfirmDeleteDialog
|
||||||
v-model="deleteDialogVisible"
|
v-model="deleteDialogVisible"
|
||||||
:loading="isDeleting"
|
:loading="isDeleting"
|
||||||
@@ -219,6 +316,7 @@ const showCreateModal = ref(false);
|
|||||||
const usersList = ref([]);
|
const usersList = ref([]);
|
||||||
const classesList = ref([]);
|
const classesList = ref([]);
|
||||||
const isCreating = ref(false);
|
const isCreating = ref(false);
|
||||||
|
const duplicateWarning = ref(null);
|
||||||
|
|
||||||
const createForm = reactive({
|
const createForm = reactive({
|
||||||
user: null,
|
user: null,
|
||||||
@@ -235,6 +333,30 @@ const createPayableAmount = computed(() => getPayableAmount({
|
|||||||
discount: hasDiscount.value ? createForm.discount : 0
|
discount: hasDiscount.value ? createForm.discount : 0
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Bulk payment state
|
||||||
|
const showBulkModal = ref(false);
|
||||||
|
const isBulkCreating = ref(false);
|
||||||
|
const hasBulkDiscount = ref(false);
|
||||||
|
const bulkNotify = reactive({ sms: true, email: true, bot: true });
|
||||||
|
const bulkForm = reactive({
|
||||||
|
classId: null,
|
||||||
|
amount: 0,
|
||||||
|
discount: 0,
|
||||||
|
notes: '',
|
||||||
|
dueDate: getTodayJalali(),
|
||||||
|
skipExisting: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedBulkClass = computed(() => {
|
||||||
|
if (!bulkForm.classId) return null;
|
||||||
|
return classesList.value.find((c) => String(c._id || c.id) === String(bulkForm.classId));
|
||||||
|
});
|
||||||
|
|
||||||
|
const bulkPayableAmount = computed(() => getPayableAmount({
|
||||||
|
amount: bulkForm.amount,
|
||||||
|
discount: hasBulkDiscount.value ? bulkForm.discount : 0
|
||||||
|
}));
|
||||||
|
|
||||||
const deleteDialogVisible = ref(false);
|
const deleteDialogVisible = ref(false);
|
||||||
const selectedPayment = ref(null);
|
const selectedPayment = ref(null);
|
||||||
const isDeleting = ref(false);
|
const isDeleting = ref(false);
|
||||||
@@ -264,11 +386,29 @@ const eligibleUsers = computed(() => {
|
|||||||
return usersList.value.filter((u) => eligibleIds?.has(String(u._id || u.id)));
|
return usersList.value.filter((u) => eligibleIds?.has(String(u._id || u.id)));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const checkDuplicateInvoice = async () => {
|
||||||
|
if (!createForm.user || !createForm.classes?.length) {
|
||||||
|
duplicateWarning.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await paymentApi.checkDuplicate({
|
||||||
|
userId: createForm.user,
|
||||||
|
classes: createForm.classes.join(',')
|
||||||
|
});
|
||||||
|
duplicateWarning.value = res.data?.data || res.data || res;
|
||||||
|
} catch (err) {
|
||||||
|
duplicateWarning.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onClassesSelected = async () => {
|
const onClassesSelected = async () => {
|
||||||
if (createForm.user && !eligibleUsers.value.some((u) => (u._id || u.id) === createForm.user)) {
|
if (createForm.user && !eligibleUsers.value.some((u) => (u._id || u.id) === createForm.user)) {
|
||||||
createForm.user = null;
|
createForm.user = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await checkDuplicateInvoice();
|
||||||
|
|
||||||
if (!createForm.classes || createForm.classes.length === 0) {
|
if (!createForm.classes || createForm.classes.length === 0) {
|
||||||
createForm.amount = 0;
|
createForm.amount = 0;
|
||||||
createForm.discount = 0;
|
createForm.discount = 0;
|
||||||
@@ -317,17 +457,115 @@ const resetCreateForm = () => {
|
|||||||
createForm.notes = '';
|
createForm.notes = '';
|
||||||
createForm.dueDate = getTodayJalali();
|
createForm.dueDate = getTodayJalali();
|
||||||
hasDiscount.value = false;
|
hasDiscount.value = false;
|
||||||
|
duplicateWarning.value = null;
|
||||||
createNotify.sms = true;
|
createNotify.sms = true;
|
||||||
createNotify.email = true;
|
createNotify.email = true;
|
||||||
createNotify.bot = true;
|
createNotify.bot = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const openCreateModal = () => {
|
const openCreateModal = () => {
|
||||||
resetCreateForm();
|
resetCreateForm();
|
||||||
showCreateModal.value = true;
|
showCreateModal.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resetBulkForm = () => {
|
||||||
|
bulkForm.classId = null;
|
||||||
|
bulkForm.amount = 0;
|
||||||
|
bulkForm.discount = 0;
|
||||||
|
bulkForm.notes = '';
|
||||||
|
bulkForm.dueDate = getTodayJalali();
|
||||||
|
bulkForm.skipExisting = true;
|
||||||
|
hasBulkDiscount.value = false;
|
||||||
|
bulkNotify.sms = true;
|
||||||
|
bulkNotify.email = true;
|
||||||
|
bulkNotify.bot = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openBulkModal = () => {
|
||||||
|
resetBulkForm();
|
||||||
|
showBulkModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBulkClassSelected = async () => {
|
||||||
|
if (!bulkForm.classId) {
|
||||||
|
bulkForm.amount = 0;
|
||||||
|
bulkForm.discount = 0;
|
||||||
|
bulkForm.dueDate = getTodayJalali();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cls = selectedBulkClass.value;
|
||||||
|
if (cls) {
|
||||||
|
bulkForm.amount = cls.tuitionFee || cls.course?.price || 0;
|
||||||
|
if (cls.hasDiscount && cls.discount) {
|
||||||
|
hasBulkDiscount.value = true;
|
||||||
|
bulkForm.discount = cls.discount;
|
||||||
|
} else {
|
||||||
|
hasBulkDiscount.value = false;
|
||||||
|
bulkForm.discount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await sessionApi.getAll({
|
||||||
|
class: bulkForm.classId,
|
||||||
|
sortBy: 'day',
|
||||||
|
sortOrder: 'asc',
|
||||||
|
limit: 100
|
||||||
|
});
|
||||||
|
const data = res.data || res;
|
||||||
|
const rawSessions = data.items || data.sessions || data.data || data || [];
|
||||||
|
const sessionList = Array.isArray(rawSessions) ? rawSessions : [];
|
||||||
|
bulkForm.dueDate = calculateClassMidDate(cls, sessionList);
|
||||||
|
} catch (e) {
|
||||||
|
bulkForm.dueDate = calculateClassMidDate(cls);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkCreatePayment = async () => {
|
||||||
|
if (!bulkForm.classId) { showError('لطفا کلاس را انتخاب کنید'); return; }
|
||||||
|
if (!selectedBulkClass.value?.students?.length) {
|
||||||
|
showError('هیچ دانشجویی در این کلاس ثبتنام نشده است');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!bulkForm.amount) { showError('لطفا مبلغ شهریه را وارد کنید'); return; }
|
||||||
|
if (!bulkForm.dueDate) { showError('لطفا تاریخ سررسید را وارد کنید'); return; }
|
||||||
|
const dueDate = toGregorianIso(bulkForm.dueDate);
|
||||||
|
if (!dueDate) { showError('تاریخ سررسید نامعتبر است'); return; }
|
||||||
|
|
||||||
|
if (hasBulkDiscount.value && (bulkForm.discount || 0) > bulkForm.amount) {
|
||||||
|
showError('مبلغ تخفیف نمیتواند بیشتر از مبلغ کل باشد');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isBulkCreating.value = true;
|
||||||
|
try {
|
||||||
|
const res = await paymentApi.createBulkClass({
|
||||||
|
classId: bulkForm.classId,
|
||||||
|
amount: bulkForm.amount,
|
||||||
|
discount: hasBulkDiscount.value ? (bulkForm.discount || 0) : 0,
|
||||||
|
dueDate,
|
||||||
|
notes: bulkForm.notes,
|
||||||
|
skipExisting: bulkForm.skipExisting,
|
||||||
|
notify: { ...bulkNotify }
|
||||||
|
});
|
||||||
|
const result = res.data?.data || res.data || res;
|
||||||
|
const createdCount = result.createdCount ?? 0;
|
||||||
|
const skippedCount = result.skippedCount ?? 0;
|
||||||
|
|
||||||
|
let msg = `صورتحساب برای ${toPersianDigits(createdCount)} دانشجو با موفقیت ایجاد شد`;
|
||||||
|
if (skippedCount > 0) {
|
||||||
|
msg += ` (${toPersianDigits(skippedCount)} دانشجو به دلیل داشتن صورتحساب قبلی رد شدند)`;
|
||||||
|
}
|
||||||
|
showSuccess(msg);
|
||||||
|
showBulkModal.value = false;
|
||||||
|
loadData();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isBulkCreating.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchDropdownData = async () => {
|
const fetchDropdownData = async () => {
|
||||||
try {
|
try {
|
||||||
const [uRes, cRes] = await Promise.all([
|
const [uRes, cRes] = await Promise.all([
|
||||||
|
|||||||
Reference in New Issue
Block a user