699 lines
25 KiB
Vue
699 lines
25 KiB
Vue
<!-- /src/views/payments/PaymentListView.vue -->
|
|
<template>
|
|
<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>
|
|
|
|
<DataTableWrapper
|
|
:items="items"
|
|
:totalCount="totalCount"
|
|
:page="queryParams.page"
|
|
:limit="queryParams.limit"
|
|
:sortBy="queryParams.sortBy"
|
|
:sortOrder="queryParams.sortOrder"
|
|
:loading="isLoading"
|
|
@page-change="onPageChange"
|
|
@sort-change="onSort"
|
|
@search-change="onSearch"
|
|
>
|
|
<Column field="user" header="نام دانشجو / کاربر">
|
|
<template #body="{ data }">
|
|
<router-link :to="`/payments/view/${data._id || data.id}`" class="font-bold text-color hover:text-primary">
|
|
{{ data.user?.name || data.userName || 'کاربر' }}
|
|
</router-link>
|
|
</template>
|
|
</Column>
|
|
|
|
<Column field="classes" header="کلاسهای مربوطه">
|
|
<template #body="{ data }">
|
|
<div class="flex flex-wrap gap-1" v-if="data.classes && data.classes.length">
|
|
<Tag v-for="c in data.classes" :key="c._id || c" :value="c.name || 'کلاس'" severity="info" class="text-xs" />
|
|
</div>
|
|
<span v-else-if="data.course" class="text-muted text-xs">{{ data.course?.title }}</span>
|
|
<span v-else class="text-muted text-xs">-</span>
|
|
</template>
|
|
</Column>
|
|
|
|
<Column field="amount" header="مبلغ کل (تومان)" sortable>
|
|
<template #body="{ data }">
|
|
<div>{{ toPersianDigits(getPayableAmount(data).toLocaleString()) }} تومان</div>
|
|
<small v-if="data.discount" class="text-muted text-xs">
|
|
تخفیف {{ toPersianDigits((data.discount || 0).toLocaleString()) }} تومان
|
|
</small>
|
|
</template>
|
|
</Column>
|
|
|
|
<Column field="paidAmount" header="مبلغ پرداختی">
|
|
<template #body="{ data }">
|
|
{{ toPersianDigits((data.paidAmount || 0).toLocaleString()) }} تومان
|
|
</template>
|
|
</Column>
|
|
|
|
<Column field="dueDate" header="تاریخ سررسید" sortable>
|
|
<template #body="{ data }">
|
|
{{ formatJalali(data.dueDate) }}
|
|
</template>
|
|
</Column>
|
|
|
|
<Column field="status" header="وضعیت">
|
|
<template #body="{ data }">
|
|
<StatusTag :status="data.status" type="payment" />
|
|
</template>
|
|
</Column>
|
|
|
|
<Column header="عملیات" style="width: 110px">
|
|
<template #body="{ data }">
|
|
<div class="flex align-items-center gap-1">
|
|
<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}`)" />
|
|
</PermissionGate>
|
|
|
|
<PermissionGate permission="payments:delete">
|
|
<Button icon="pi pi-trash" text rounded size="small" severity="danger" v-tooltip.top="'حذف'" @click="confirmDelete(data)" />
|
|
</PermissionGate>
|
|
</div>
|
|
</template>
|
|
</Column>
|
|
</DataTableWrapper>
|
|
|
|
<!-- Create Payment Modal -->
|
|
<Dialog v-model:visible="showCreateModal" header="ایجاد صورتحساب جدید" modal :style="{ width: '520px' }">
|
|
<div class="flex flex-column gap-3 py-2">
|
|
<div class="flex flex-column gap-2">
|
|
<label class="font-semibold text-sm">انتخاب یک یا چند کلاس *</label>
|
|
<MultiSelect
|
|
v-model="createForm.classes"
|
|
:options="classesList"
|
|
optionLabel="name"
|
|
optionValue="_id"
|
|
display="chip"
|
|
filter
|
|
placeholder="ابتدا کلاسها را انتخاب کنید"
|
|
class="w-full text-sm"
|
|
@change="onClassesSelected"
|
|
/>
|
|
</div>
|
|
|
|
<div class="flex flex-column gap-2">
|
|
<label class="font-semibold text-sm">انتخاب کاربر / دانشجو *</label>
|
|
<Dropdown
|
|
v-model="createForm.user"
|
|
:options="eligibleUsers"
|
|
optionLabel="fullName"
|
|
optionValue="_id"
|
|
filter
|
|
: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 && duplicateWarning.hasDuplicate"
|
|
class="p-3 border-round flex align-items-start gap-2 duplicate-warning-box"
|
|
>
|
|
<i class="pi pi-exclamation-triangle text-xl text-yellow-400 mt-1 flex-shrink-0"></i>
|
|
<div class="flex flex-column gap-1">
|
|
<span class="font-bold text-yellow-300 text-sm">هشدار: برای این دانشجو قبلاً در این کلاس صورتحساب ثبت شده است.</span>
|
|
<span class="text-xs text-yellow-200" v-if="duplicateWarning.payments && duplicateWarning.payments.length">
|
|
صورتحساب قبلی با کد {{ duplicateWarning.payments.map(p => p.uniqueCode || p._id).join('، ') }} موجود است.
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-column gap-2">
|
|
<label class="font-semibold text-sm" for="invoice-amount">مبلغ کل صورتحساب (تومان) *</label>
|
|
<InputGroup>
|
|
<InputNumber inputId="invoice-amount" v-model="createForm.amount" class="w-full text-sm" :min="0" />
|
|
<InputGroupAddon>تومان</InputGroupAddon>
|
|
</InputGroup>
|
|
</div>
|
|
|
|
<div class="flex align-items-center gap-2">
|
|
<Checkbox v-model="hasDiscount" binary inputId="invoice-discount" />
|
|
<label for="invoice-discount" class="font-semibold text-sm cursor-pointer">تخفیف ؟</label>
|
|
</div>
|
|
|
|
<div v-if="hasDiscount" class="flex flex-column gap-2">
|
|
<label class="font-semibold text-sm" for="invoice-discount-amount">مبلغ تخفیف (تومان)</label>
|
|
<InputGroup>
|
|
<InputNumber
|
|
inputId="invoice-discount-amount"
|
|
v-model="createForm.discount"
|
|
class="w-full text-sm"
|
|
:min="0"
|
|
:max="createForm.amount || 0"
|
|
/>
|
|
<InputGroupAddon>تومان</InputGroupAddon>
|
|
</InputGroup>
|
|
<small class="font-semibold text-color">
|
|
مبلغ نهایی: {{ toPersianDigits(createPayableAmount.toLocaleString()) }} تومان
|
|
</small>
|
|
</div>
|
|
|
|
<div class="flex flex-column gap-2">
|
|
<label class="font-semibold text-sm">تاریخ سررسید *</label>
|
|
<DatePicker v-model="createForm.dueDate" class="w-full text-sm" :placeholder="getTodayJalali()" />
|
|
</div>
|
|
|
|
<div class="flex flex-column gap-2">
|
|
<label class="font-semibold text-sm" for="invoice-notes">یادداشت</label>
|
|
<Textarea
|
|
id="invoice-notes"
|
|
v-model="createForm.notes"
|
|
rows="3"
|
|
class="w-full text-sm"
|
|
maxlength="5000"
|
|
placeholder="یادداشت داخلی درباره این صورتحساب…"
|
|
/>
|
|
</div>
|
|
|
|
<NotifyChannelsField :notify="createNotify" />
|
|
</div>
|
|
<template #footer>
|
|
<Button label="انصراف" text severity="secondary" @click="showCreateModal = false" />
|
|
<Button label="ایجاد صورتحساب" icon="pi pi-check" severity="success" :loading="isCreating" @click="handleCreatePayment" />
|
|
</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>
|
|
<InputGroup>
|
|
<InputNumber inputId="bulk-invoice-amount" v-model="bulkForm.amount" class="w-full text-sm" :min="0" />
|
|
<InputGroupAddon>تومان</InputGroupAddon>
|
|
</InputGroup>
|
|
</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>
|
|
<InputGroup>
|
|
<InputNumber
|
|
inputId="bulk-invoice-discount-amount"
|
|
v-model="bulkForm.discount"
|
|
class="w-full text-sm"
|
|
:min="0"
|
|
:max="bulkForm.amount || 0"
|
|
/>
|
|
<InputGroupAddon>تومان</InputGroupAddon>
|
|
</InputGroup>
|
|
<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"
|
|
@confirm="handleDelete"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, reactive, computed, watch, onMounted } from 'vue';
|
|
import { useDataTable } from '@/composables/useDataTable';
|
|
import { usePersianDate } from '@/composables/usePersianDate';
|
|
import { useToast } from '@/composables/useToast';
|
|
import { paymentApi } from '@/api/paymentApi';
|
|
import { userApi } from '@/api/userApi';
|
|
import { classApi } from '@/api/classApi';
|
|
import { sessionApi } from '@/api/sessionApi';
|
|
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 NotifyChannelsField from '@/components/common/NotifyChannelsField.vue';
|
|
import { getPayableAmount } from '@/utils/paymentAmount';
|
|
import { calculateClassMidDate } from '@/utils/classSchedule';
|
|
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 MultiSelect from 'primevue/multiselect';
|
|
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(paymentApi.getAll);
|
|
|
|
const showCreateModal = ref(false);
|
|
const usersList = ref([]);
|
|
const classesList = ref([]);
|
|
const isCreating = ref(false);
|
|
const duplicateWarning = ref(null);
|
|
|
|
const createForm = reactive({
|
|
user: null,
|
|
classes: [],
|
|
amount: 0,
|
|
discount: 0,
|
|
notes: '',
|
|
dueDate: getTodayJalali()
|
|
});
|
|
const hasDiscount = ref(false);
|
|
const createNotify = reactive({ sms: true, email: true, bot: true });
|
|
const createPayableAmount = computed(() => getPayableAmount({
|
|
amount: createForm.amount,
|
|
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);
|
|
|
|
const studentIdOf = (student) => String(student?._id || student?.id || student);
|
|
|
|
const eligibleUsers = computed(() => {
|
|
if (!createForm.classes?.length) return [];
|
|
|
|
const selectedIds = new Set(createForm.classes.map(String));
|
|
const selectedClasses = classesList.value.filter((c) =>
|
|
selectedIds.has(String(c._id || c.id))
|
|
);
|
|
if (!selectedClasses.length) return [];
|
|
|
|
// Intersection: user must be registered in every selected class
|
|
let eligibleIds = null;
|
|
selectedClasses.forEach((cls) => {
|
|
const ids = new Set((cls.students || []).map(studentIdOf));
|
|
if (eligibleIds === null) {
|
|
eligibleIds = ids;
|
|
} else {
|
|
eligibleIds = new Set([...eligibleIds].filter((id) => ids.has(id)));
|
|
}
|
|
});
|
|
|
|
return usersList.value.filter((u) => eligibleIds?.has(String(u._id || u.id)));
|
|
});
|
|
|
|
const extractId = (val) => {
|
|
if (!val) return null;
|
|
if (typeof val === 'object') {
|
|
if (val.value !== undefined && (typeof val.value === 'string' || typeof val.value === 'number')) {
|
|
return String(val.value);
|
|
}
|
|
return val._id ? String(val._id) : (val.id ? String(val.id) : null);
|
|
}
|
|
return String(val);
|
|
};
|
|
|
|
const checkDuplicateInvoice = async (userIdVal = null) => {
|
|
const targetUserId = extractId(userIdVal) || extractId(createForm.user);
|
|
if (!targetUserId || !createForm.classes || !createForm.classes.length) {
|
|
duplicateWarning.value = null;
|
|
return;
|
|
}
|
|
try {
|
|
const classIds = Array.isArray(createForm.classes)
|
|
? createForm.classes.map(extractId).filter(Boolean).join(',')
|
|
: (extractId(createForm.classes) || '');
|
|
if (!classIds) {
|
|
duplicateWarning.value = null;
|
|
return;
|
|
}
|
|
const res = await paymentApi.checkDuplicate({
|
|
userId: targetUserId,
|
|
classes: classIds
|
|
});
|
|
duplicateWarning.value = res.data?.data || res.data || res;
|
|
} catch (err) {
|
|
console.error('checkDuplicateInvoice error:', err);
|
|
duplicateWarning.value = null;
|
|
}
|
|
};
|
|
|
|
watch(
|
|
[() => createForm.user, () => createForm.classes],
|
|
async ([newUser, newClasses]) => {
|
|
if (newUser && newClasses && newClasses.length) {
|
|
await checkDuplicateInvoice(newUser);
|
|
} else {
|
|
duplicateWarning.value = null;
|
|
}
|
|
},
|
|
{ deep: true, immediate: true }
|
|
);
|
|
|
|
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;
|
|
createForm.dueDate = getTodayJalali();
|
|
return;
|
|
}
|
|
let totalFee = 0;
|
|
createForm.classes.forEach((classId) => {
|
|
const c = classesList.value.find((item) => String(item._id || item.id) === String(classId));
|
|
if (c) {
|
|
totalFee += (c.tuitionFee || c.course?.price || 0);
|
|
}
|
|
});
|
|
if (totalFee > 0) {
|
|
createForm.amount = totalFee;
|
|
}
|
|
if ((createForm.discount || 0) > (createForm.amount || 0)) {
|
|
createForm.discount = createForm.amount || 0;
|
|
}
|
|
|
|
const selectedClassId = createForm.classes[createForm.classes.length - 1];
|
|
const selectedClass = classesList.value.find((item) => String(item._id || item.id) === String(selectedClassId));
|
|
if (selectedClass) {
|
|
try {
|
|
const res = await sessionApi.getAll({
|
|
class: selectedClassId,
|
|
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 : [];
|
|
createForm.dueDate = calculateClassMidDate(selectedClass, sessionList);
|
|
} catch (e) {
|
|
createForm.dueDate = calculateClassMidDate(selectedClass);
|
|
}
|
|
}
|
|
};
|
|
|
|
const resetCreateForm = () => {
|
|
createForm.user = null;
|
|
createForm.classes = [];
|
|
createForm.amount = 0;
|
|
createForm.discount = 0;
|
|
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([
|
|
userApi.getAll({ limit: 200 }),
|
|
classApi.getAll({ limit: 100 })
|
|
]);
|
|
const uData = uRes.data || uRes;
|
|
const rawUsers = uData.items || uData.users || uData || [];
|
|
usersList.value = rawUsers.map((u) => ({ ...u, fullName: u.name || '' }));
|
|
|
|
const cData = cRes.data || cRes;
|
|
classesList.value = cData.items || cData.classes || cData || [];
|
|
} catch (e) {
|
|
console.warn('Dropdown fetch error:', e);
|
|
}
|
|
};
|
|
|
|
const handleCreatePayment = async () => {
|
|
if (!createForm.classes?.length) { showError('لطفا کلاس را انتخاب کنید'); return; }
|
|
if (!createForm.user) { showError('لطفا کاربر را انتخاب کنید'); return; }
|
|
if (!createForm.amount) { showError('لطفا مبلغ را وارد کنید'); return; }
|
|
if (!createForm.dueDate) { showError('لطفا تاریخ سررسید را وارد کنید'); return; }
|
|
const dueDate = toGregorianIso(createForm.dueDate);
|
|
if (!dueDate) { showError('تاریخ سررسید نامعتبر است'); return; }
|
|
|
|
if (hasDiscount.value && (createForm.discount || 0) > createForm.amount) {
|
|
showError('مبلغ تخفیف نمیتواند بیشتر از مبلغ کل باشد');
|
|
return;
|
|
}
|
|
|
|
isCreating.value = true;
|
|
try {
|
|
await paymentApi.create({
|
|
...createForm,
|
|
dueDate,
|
|
discount: hasDiscount.value ? (createForm.discount || 0) : 0,
|
|
notify: { ...createNotify }
|
|
});
|
|
showSuccess('صورتحساب جدید با موفقیت ایجاد شد');
|
|
showCreateModal.value = false;
|
|
loadData();
|
|
} catch (err) {
|
|
showError(err);
|
|
} finally {
|
|
isCreating.value = false;
|
|
}
|
|
};
|
|
|
|
const confirmDelete = (payment) => {
|
|
selectedPayment.value = payment;
|
|
deleteDialogVisible.value = true;
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!selectedPayment.value) return;
|
|
isDeleting.value = true;
|
|
try {
|
|
await paymentApi.delete(selectedPayment.value._id || selectedPayment.value.id);
|
|
showSuccess('صورتحساب با موفقیت حذف شد');
|
|
deleteDialogVisible.value = false;
|
|
loadData();
|
|
} catch (err) {
|
|
showError(err);
|
|
} finally {
|
|
isDeleting.value = false;
|
|
}
|
|
};
|
|
|
|
onMounted(() => {
|
|
loadData();
|
|
fetchDropdownData();
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.duplicate-warning-box {
|
|
background: rgba(234, 179, 8, 0.15) !important;
|
|
border: 1px solid rgba(234, 179, 8, 0.6) !important;
|
|
color: #fef08a !important;
|
|
}
|
|
</style>
|