feat(payments): add bulk payment modal and duplicate payment warning in dialogs

This commit is contained in:
2026-08-23 13:28:11 +03:30
parent 60b21fd94a
commit f7089d5ca7
3 changed files with 413 additions and 4 deletions
+239 -1
View File
@@ -3,6 +3,7 @@
<div class="payment-list-view">
<PageHeader :title="$t('payments.title')" :subtitle="$t('payments.subtitle')">
<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" />
</PermissionGate>
</PageHeader>
@@ -108,12 +109,22 @@
:placeholder="createForm.classes?.length ? 'دانشجوی ثبت‌نام‌شده را انتخاب کنید' : 'ابتدا کلاس را انتخاب کنید'"
class="w-full text-sm"
:disabled="!createForm.classes?.length"
@change="checkDuplicateInvoice"
/>
<small v-if="createForm.classes?.length && !eligibleUsers.length" class="text-orange-500">
هیچ دانشجوی ثبت‌نام‌شده‌ای در کلاس‌های انتخاب‌شده یافت نشد
</small>
</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">
<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" />
@@ -164,6 +175,92 @@
</template>
</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
v-model="deleteDialogVisible"
:loading="isDeleting"
@@ -219,6 +316,7 @@ const showCreateModal = ref(false);
const usersList = ref([]);
const classesList = ref([]);
const isCreating = ref(false);
const duplicateWarning = ref(null);
const createForm = reactive({
user: null,
@@ -235,6 +333,30 @@ const createPayableAmount = computed(() => getPayableAmount({
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 selectedPayment = ref(null);
const isDeleting = ref(false);
@@ -264,11 +386,29 @@ const eligibleUsers = computed(() => {
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 () => {
if (createForm.user && !eligibleUsers.value.some((u) => (u._id || u.id) === createForm.user)) {
createForm.user = null;
}
await checkDuplicateInvoice();
if (!createForm.classes || createForm.classes.length === 0) {
createForm.amount = 0;
createForm.discount = 0;
@@ -317,17 +457,115 @@ const resetCreateForm = () => {
createForm.notes = '';
createForm.dueDate = getTodayJalali();
hasDiscount.value = false;
duplicateWarning.value = null;
createNotify.sms = true;
createNotify.email = true;
createNotify.bot = true;
};
const openCreateModal = () => {
resetCreateForm();
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 () => {
try {
const [uRes, cRes] = await Promise.all([